From 11460fe11c28a636c4becf80eea7fb8b15853113 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 7 Jul 2026 20:46:17 +0000 Subject: [PATCH 1/5] Add aggregate cloud and agent-server clients --- scripts/rewrite-relative-imports.mjs | 7 +- src/__tests__/api-clients.test.ts | 114 ++++ src/client/cloud-client.ts | 860 +++++++++++++++++++++++++++ src/client/device-flow-client.ts | 236 ++++++++ src/client/openhands-client.ts | 208 +++++++ src/clients.ts | 48 ++ 6 files changed, 1472 insertions(+), 1 deletion(-) create mode 100644 src/client/cloud-client.ts create mode 100644 src/client/device-flow-client.ts create mode 100644 src/client/openhands-client.ts diff --git a/scripts/rewrite-relative-imports.mjs b/scripts/rewrite-relative-imports.mjs index 65eb9d8b..7b8ed11d 100644 --- a/scripts/rewrite-relative-imports.mjs +++ b/scripts/rewrite-relative-imports.mjs @@ -9,6 +9,8 @@ const KNOWN_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.json', '.node']); const TARGET_SUFFIXES = ['.js', '.d.ts', '.d.mts', '.d.cts']; const hasKnownExtension = (specifier) => KNOWN_EXTENSIONS.has(path.posix.extname(specifier)); +const isJsonImport = (prefix, specifier, filePath) => + filePath.endsWith('.js') && prefix.startsWith('from') && specifier.endsWith('.json'); const normalizeRelativeSpecifier = (specifier, filePath) => { if (!specifier.startsWith('.') || hasKnownExtension(specifier)) { @@ -46,7 +48,10 @@ const rewriteFile = async (filePath) => { IMPORT_EXPORT_SPECIFIER_PATTERN, (_fullMatch, prefix, quote, specifier) => { const normalizedSpecifier = normalizeRelativeSpecifier(specifier, filePath); - return `${prefix}${quote}${normalizedSpecifier}${quote}`; + const rewrittenSpecifier = `${prefix}${quote}${normalizedSpecifier}${quote}`; + return isJsonImport(prefix, normalizedSpecifier, filePath) + ? `${rewrittenSpecifier} with { type: 'json' }` + : rewrittenSpecifier; } ); diff --git a/src/__tests__/api-clients.test.ts b/src/__tests__/api-clients.test.ts index f242c5cc..d3435ddd 100644 --- a/src/__tests__/api-clients.test.ts +++ b/src/__tests__/api-clients.test.ts @@ -11,14 +11,18 @@ import { } from '../index'; import { AgentProfilesClient, + AgentServerClient, AgentServerVersionError, BashClient, clearAgentServerInfoCache, + CloudClient, compareAgentServerVersions, ConversationClient, + DeviceFlowError, FileClient, HooksClient, isAgentServerVersionError, + isOpenHandsCloudHost, LLMMetadataClient, MCPClient, MetaProfilesClient, @@ -105,6 +109,116 @@ describe('Auxiliary API clients', () => { expect(manager.mcp).toBeInstanceOf(MCPClient); }); + describe('Aggregate clients', () => { + it('AgentServerClient preserves the existing endpoint clients behind namespaces', async () => { + global.fetch = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ status: 'ok' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) as typeof fetch; + + const client = new AgentServerClient({ host: 'http://example.com/', apiKey: 'secret' }); + + expect(client.kind).toBe('agent-server'); + expect(client.server).toBeInstanceOf(ServerClient); + expect(client.conversations).toBeInstanceOf(ConversationClient); + expect(client.settings).toBeInstanceOf(SettingsClient); + expect(client.host).toBe('http://example.com'); + + await client.request({ method: 'GET', path: '/health' }); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://example.com/health', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'X-Session-API-Key': 'secret', + }), + }) + ); + }); + + it('CloudClient sends bearer auth and X-Org-Id for cloud app-host requests', async () => { + global.fetch = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ items: [], current_org_id: 'org-1' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) as typeof fetch; + + const client = new CloudClient({ + host: 'https://app.all-hands.dev/', + apiKey: 'cloud-key', + orgId: 'org-1', + }); + const result = await client.getOrganizations(); + + expect(result).toEqual({ items: [], currentOrgId: 'org-1' }); + expect(global.fetch).toHaveBeenCalledWith( + 'https://app.all-hands.dev/api/organizations', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: 'Bearer cloud-key', + 'X-Org-Id': 'org-1', + }), + }) + ); + }); + + it('CloudClient routes hostOverride requests through the configured proxy', async () => { + global.fetch = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) as typeof fetch; + + const client = new CloudClient({ + host: 'https://app.all-hands.dev', + apiKey: 'cloud-key', + proxy: { + host: 'http://localhost:8001', + apiKey: 'local-key', + }, + }); + + await client.request({ + method: 'POST', + hostOverride: 'https://runtime.example.com', + path: '/api/conversations/c1/events', + body: { role: 'user' }, + authMode: 'session-api-key', + sessionApiKey: 'runtime-key', + }); + + expect(global.fetch).toHaveBeenCalledWith( + 'http://localhost:8001/api/cloud-proxy', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'X-Session-API-Key': 'local-key', + }), + }) + ); + const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body as string); + expect(body).toEqual({ + host: 'https://runtime.example.com', + method: 'POST', + path: '/api/conversations/c1/events', + headers: { 'X-Session-API-Key': 'runtime-key' }, + body: { role: 'user' }, + }); + }); + + it('exports cloud device-flow helpers from the clients entrypoint', () => { + expect(isOpenHandsCloudHost('https://app.all-hands.dev')).toBe(true); + expect(isOpenHandsCloudHost('https://all-hands.dev.evil.example')).toBe(false); + expect(new DeviceFlowError('denied', 'access_denied').code).toBe('access_denied'); + }); + }); + describe('AgentProfilesClient', () => { it('listAgentProfiles fetches /api/agent-profiles', async () => { const payload = { diff --git a/src/client/cloud-client.ts b/src/client/cloud-client.ts new file mode 100644 index 00000000..bba47054 --- /dev/null +++ b/src/client/cloud-client.ts @@ -0,0 +1,860 @@ +import { HttpError, type ResponseType } from './http-client'; +import { + OpenHandsClient, + type OpenHandsClientOptions, + type OpenHandsRequestOptions, +} from './openhands-client'; +import { + pollForToken, + startDeviceFlow, + type DeviceAuthorizationResponse, + type DeviceTokenResponse, + type PollDeviceTokenOptions, +} from './device-flow-client'; +import type { + ActivateProfileResponse, + ProfileDetailResponse, + ProfileListResponse, + ProfileMutationResponse, + SaveProfileRequest, + SettingsSchema, +} from '../models/api'; + +export interface CloudProxyOptions { + /** Agent-server or ingress host exposing `/api/cloud-proxy`. */ + host: string; + /** Optional local session API key for the proxy endpoint. */ + apiKey?: string; + /** Additional headers for the proxy endpoint itself. */ + headers?: Record; + /** Defaults to `/api/cloud-proxy`. */ + path?: string; +} + +export interface CloudClientOptions extends OpenHandsClientOptions { + /** Locally selected org. Sent as `X-Org-Id` on cloud app-host requests. */ + orgId?: string | null; + /** Runtime-sandbox requests with `hostOverride` are tunneled through this proxy. */ + proxy?: CloudProxyOptions; +} + +export interface CloudRequestOptions extends OpenHandsRequestOptions { + timeoutSeconds?: number; +} + +export interface CloudOrganization { + id: string; + name: string; + is_personal?: boolean; +} + +export interface CloudOrganizationsResponse { + items: CloudOrganization[]; + current_org_id: string | null; +} + +export interface CloudOrganizationsResult { + items: CloudOrganization[]; + currentOrgId: string | null; +} + +export interface CloudApiKeyMetadata { + id: string; + name: string; + org_id: string | null; + user_id: string; + auth_type: string; +} + +export interface CloudOrganizationMe { + orgId: string; + userId: string; + role: string | null; + permissions?: string[] | null; +} + +export type CloudSettingsValue = + boolean | number | string | null | CloudSettingsValue[] | { [key: string]: CloudSettingsValue }; + +export interface CloudSettingsResponse { + llm_model?: string; + llm_base_url?: string; + llm_api_key?: string | null; + llm_api_key_set?: boolean; + search_api_key_set?: boolean; + agent?: string; + confirmation_mode?: boolean; + security_analyzer?: string | null; + max_iterations?: number | null; + enable_default_condenser?: boolean; + condenser_max_size?: number | null; + provider_tokens_set?: Partial>; + mcp_config?: Record; + disabled_skills?: string[]; + agent_settings?: Record | null; + conversation_settings?: Record | null; + agent_settings_schema?: unknown; + conversation_settings_schema?: unknown; + [key: string]: unknown; +} + +export interface SaveCloudSettingsRequest { + agent_settings_diff?: Record; + conversation_settings_diff?: Record; + app_preferences?: Record; +} + +export interface CloudSecret { + name: string; + value?: string; + description?: string; +} + +export type CloudSecretWithoutValue = Omit; + +export interface CloudSkillInfo { + name: string; + type: 'repo' | 'knowledge' | 'agentskills'; + source?: string | null; + description?: string | null; + triggers?: string[]; + version?: string; + license?: string | null; + compatibility?: string | null; + metadata?: Record | null; + allowed_tools?: string[] | null; + is_agentskills_format?: boolean; + disable_model_invocation?: boolean; + content?: string; +} + +export interface CloudGitRepository { + id: string; + full_name: string; + git_provider: string; + is_public: boolean; + stargazers_count?: number; + link_header?: string; + pushed_at?: string; + main_branch?: string; +} + +export interface CloudGitBranch { + name: string; + commit_sha: string; + protected: boolean; + last_push_date?: string; +} + +export interface CloudPage { + items: T[]; + next_page_id: string | null; +} + +export type CloudRepositoryPage = CloudPage; +export type CloudBranchPage = CloudPage; +export type CloudInstallationPage = CloudPage; + +export interface CloudSuggestedTask { + git_provider: string; + issue_number: number; + repo: string; + title: string; + task_type: string; +} + +export interface CloudConversationStartRequest { + conversation_id?: string | null; + initial_message?: unknown; + processors?: unknown[]; + llm_model?: string | null; + selected_repository?: string | null; + selected_branch?: string | null; + git_provider?: string | null; + suggested_task?: CloudSuggestedTask | null; + title?: string | null; + trigger?: string | null; + pr_number?: number[]; + parent_conversation_id?: string | null; + agent_type?: 'default' | 'plan'; + sandbox_id?: string | null; + plugins?: unknown[] | null; +} + +export interface CloudConversationStartTask { + id: string; + created_by_user_id: string | null; + status: string; + detail: string | null; + app_conversation_id: string | null; + agent_server_url: string | null; + request: CloudConversationStartRequest; + created_at: string; + updated_at: string; +} + +export interface CloudAppConversation { + id: string; + created_by_user_id: string | null; + selected_repository: string | null; + selected_branch: string | null; + git_provider: string | null; + title: string | null; + trigger: string | null; + pr_number: number[]; + llm_model: string | null; + metrics: unknown; + created_at: string; + updated_at: string; + execution_status: string | null; + sandbox_status?: string | null; + conversation_url: string | null; + session_api_key: string | null; + sandbox_id: string | null; + workspace?: { working_dir: string | null } | null; + public?: boolean; + sub_conversation_ids: string[]; + [key: string]: unknown; +} + +export type CloudConversationPage = CloudPage; + +export interface CloudSandboxInfo { + id: string; + created_by_user_id: string | null; + sandbox_spec_id: string; + status: 'STARTING' | 'RUNNING' | 'PAUSED' | 'ERROR' | 'MISSING'; + session_api_key: string | null; + exposed_urls: Array<{ name: string; url: string }> | null; + created_at: string; +} + +interface CloudSecretsPage { + items: CloudSecretWithoutValue[]; + next_page_id: string | null; +} + +interface CloudSkillsPage { + items: CloudSkillInfo[]; + next_page_id: string | null; +} + +const SETTINGS_PROFILES_PATH = '/api/v1/settings/profiles'; +const DEFAULT_PAGE_LIMIT = 100; + +export class CloudClient extends OpenHandsClient { + readonly kind = 'cloud' as const; + readonly orgId: string | null; + readonly proxy?: CloudProxyOptions; + + constructor(options: CloudClientOptions) { + super(options); + this.orgId = options.orgId ?? null; + this.proxy = options.proxy + ? { + ...options.proxy, + host: options.proxy.host.replace(/\/+$/, ''), + path: options.proxy.path ?? '/api/cloud-proxy', + } + : undefined; + } + + async request(options: CloudRequestOptions): Promise { + return options.hostOverride + ? this.requestThroughProxy(options) + : this.requestDirect(options); + } + + startDeviceFlow(): Promise { + return startDeviceFlow(this.host); + } + + pollForToken(deviceCode: string, options: PollDeviceTokenOptions): Promise { + return pollForToken(this.host, deviceCode, options); + } + + async getOrganizations(): Promise { + const data = await this.get('/api/organizations'); + return { + items: data?.items ?? [], + currentOrgId: data?.current_org_id ?? null, + }; + } + + getCurrentApiKey(): Promise { + return this.get('/api/keys/current'); + } + + async getOrganizationMe(orgId: string): Promise { + const data = await this.get<{ + org_id: string; + user_id: string; + role?: string; + permissions?: string[]; + }>(`/api/organizations/${encodeURIComponent(orgId)}/me`); + return { + orgId: data?.org_id ?? orgId, + userId: data?.user_id ?? '', + role: data?.role ?? null, + permissions: Array.isArray(data?.permissions) ? data.permissions : null, + }; + } + + getSettings(): Promise { + return this.get('/api/v1/settings'); + } + + async getSettingsWithDerivedFields(): Promise { + const flat = await this.getSettings(); + return { + ...flat, + agent_settings: deriveAgentSettings(flat), + conversation_settings: deriveConversationSettings(flat), + llm_api_key_set: !!flat.llm_api_key_set, + search_api_key_set: !!flat.search_api_key_set, + provider_tokens_set: flat.provider_tokens_set, + }; + } + + async saveSettings(diff: SaveCloudSettingsRequest): Promise { + const body: Record = {}; + if (diff.agent_settings_diff) { + const agentDiff = { ...diff.agent_settings_diff }; + if (agentDiff.agent_context === null) { + delete agentDiff.agent_context; + } + if (Object.keys(agentDiff).length > 0) { + body.agent_settings_diff = agentDiff; + } + } + if ( + diff.conversation_settings_diff && + Object.keys(diff.conversation_settings_diff).length > 0 + ) { + body.conversation_settings_diff = diff.conversation_settings_diff; + } + if (diff.app_preferences) { + for (const [key, value] of Object.entries(diff.app_preferences)) { + if (value !== undefined) { + body[key] = value; + } + } + } + await this.post('/api/v1/settings', body); + } + + getSettingsSchema(): Promise { + return this.get('/api/v1/settings/agent-schema'); + } + + getConversationSettingsSchema(): Promise { + return this.get('/api/v1/settings/conversation-schema'); + } + + listProfiles(): Promise { + return this.get(this.profileBasePath()); + } + + async getProfile(name: string): Promise { + const result = await this.get<{ + name: string; + config?: Record; + llm?: Record; + api_key_set?: boolean; + }>(`${this.profileBasePath()}/${encodeURIComponent(name)}`); + return { + name: result.name, + config: result.config ?? result.llm ?? {}, + api_key_set: result.api_key_set ?? false, + }; + } + + saveProfile(name: string, request: SaveProfileRequest): Promise { + return this.post( + `${this.profileBasePath()}/${encodeURIComponent(name)}`, + request + ); + } + + deleteProfile(name: string): Promise { + return this.delete( + `${this.profileBasePath()}/${encodeURIComponent(name)}` + ); + } + + renameProfile(name: string, newName: string): Promise { + return this.post( + `${this.profileBasePath()}/${encodeURIComponent(name)}/rename`, + { new_name: newName } + ); + } + + async activateProfile(name: string): Promise { + const result = await this.post<{ + name: string; + message: string; + model?: string | null; + llm?: Record | null; + }>(`${this.profileBasePath()}/${encodeURIComponent(name)}/activate`, {}); + return { + name: result.name, + message: result.message, + llm_applied: result.model != null || result.llm != null, + }; + } + + async listSecrets(): Promise { + const secrets: CloudSecretWithoutValue[] = []; + let pageId: string | null = null; + do { + const query = new URLSearchParams({ limit: String(DEFAULT_PAGE_LIMIT) }); + if (pageId) query.set('page_id', pageId); + const page = await this.get(`/api/v1/secrets/search?${query.toString()}`); + secrets.push(...(page.items ?? [])); + pageId = page.next_page_id; + } while (pageId); + return secrets; + } + + async createSecret(name: string, value: string, description?: string): Promise { + await this.post('/api/v1/secrets', { name, value, description }); + } + + async updateSecret(secretToEdit: string, name: string, description?: string): Promise { + await this.put(`/api/v1/secrets/${encodeURIComponent(secretToEdit)}`, { + name, + description, + }); + } + + async deleteSecret(name: string): Promise { + await this.delete(`/api/v1/secrets/${encodeURIComponent(name)}`); + } + + async listSkills(): Promise { + const skills: CloudSkillInfo[] = []; + let pageId: string | null = null; + do { + const query = new URLSearchParams({ limit: String(DEFAULT_PAGE_LIMIT) }); + if (pageId) query.set('page_id', pageId); + const page = await this.get(`/api/v1/skills/search?${query.toString()}`); + skills.push(...(page.items ?? [])); + pageId = page.next_page_id; + } while (pageId); + return skills; + } + + searchRepositories(args: { + provider: string; + query?: string; + limit?: number; + pageId?: string; + installationId?: string; + }): Promise { + const params = new URLSearchParams(); + params.set('provider', args.provider); + params.set('limit', String(args.limit ?? 100)); + if (args.query) params.set('query', args.query); + if (args.pageId) params.set('page_id', args.pageId); + if (args.installationId) params.set('installation_id', args.installationId); + return this.get(`/api/v1/git/repositories/search?${params.toString()}`); + } + + getInstallations(args: { + provider: string; + pageId?: string; + limit?: number; + }): Promise { + const params = new URLSearchParams(); + params.set('provider', args.provider); + params.set('limit', String(args.limit ?? 100)); + if (args.pageId) params.set('page_id', args.pageId); + return this.get(`/api/v1/git/installations/search?${params.toString()}`); + } + + getRepositoryBranches(args: { + provider: string; + repository: string; + query?: string; + pageId?: string; + limit?: number; + }): Promise { + const params = new URLSearchParams(); + params.set('provider', args.provider); + params.set('repository', args.repository); + params.set('limit', String(args.limit ?? 30)); + params.set('query', args.query ?? ''); + if (args.pageId) params.set('page_id', args.pageId); + return this.get(`/api/v1/git/branches/search?${params.toString()}`); + } + + getSuggestedTasks( + args: { + pageId?: string; + limit?: number; + } = {} + ): Promise> { + const params = new URLSearchParams(); + params.set('limit', String(args.limit ?? 30)); + if (args.pageId) params.set('page_id', args.pageId); + return this.get>( + `/api/v1/git/suggested-tasks/search?${params.toString()}` + ); + } + + searchModels(params: Record) { + return this.get(`/api/v1/config/models/search${buildQuerySuffix(params)}`); + } + + searchProviders(params: Record) { + return this.get(`/api/v1/config/providers/search${buildQuerySuffix(params)}`); + } + + searchConversations(limit = 20, pageId?: string): Promise { + const params = new URLSearchParams(); + params.set('limit', String(limit)); + if (pageId) params.set('page_id', pageId); + params.set('sort_order', 'UPDATED_AT_DESC'); + return this.get(`/api/v1/app-conversations/search?${params.toString()}`); + } + + getConversations(ids: string[]): Promise> { + if (ids.length === 0) return Promise.resolve([]); + const params = new URLSearchParams(); + for (const id of ids) params.append('ids', id); + return this.get>(`/api/v1/app-conversations?${params}`); + } + + createConversation(request: CloudConversationStartRequest): Promise { + return this.post('/api/v1/app-conversations', request); + } + + downloadConversation(conversationId: string): Promise { + return this.get(`/api/v1/app-conversations/${conversationId}/download`, { + responseType: 'blob', + }); + } + + async deleteConversation(conversationId: string): Promise { + await this.delete(`/api/v1/app-conversations/${conversationId}`); + } + + updateConversationPublicFlag( + conversationId: string, + isPublic: boolean + ): Promise { + return this.patch(`/api/v1/app-conversations/${conversationId}`, { + public: isPublic, + }); + } + + async pauseSandbox(sandboxId: string): Promise { + await this.post(`/api/v1/sandboxes/${sandboxId}/pause`, {}); + } + + async resumeSandbox(sandboxId: string): Promise { + await this.post(`/api/v1/sandboxes/${sandboxId}/resume`, {}); + } + + getSandboxes(ids: string[]): Promise> { + if (ids.length === 0) return Promise.resolve([]); + const params = new URLSearchParams(); + for (const id of ids) params.append('id', id); + return this.get>(`/api/v1/sandboxes?${params}`); + } + + readConversationFile(conversationId: string, filePath: string): Promise { + const query = new URLSearchParams({ file_path: filePath }); + return this.get(`/api/v1/app-conversations/${conversationId}/file?${query}`, { + responseType: 'text', + }); + } + + async getConversationStartTask(taskId: string): Promise { + const params = new URLSearchParams(); + params.append('ids', taskId); + const data = await this.get>( + `/api/v1/app-conversations/start-tasks?${params}` + ); + return data?.[0] ?? null; + } + + async switchConversationProfile(conversationId: string, profileName: string): Promise { + await this.post(`/api/v1/app-conversations/${conversationId}/switch_profile`, { + profile_name: profileName, + }); + } + + async switchConversationAcpModel(conversationId: string, model: string): Promise { + await this.post(`/api/v1/app-conversations/${conversationId}/switch_acp_model`, { model }); + } + + async listAutomations(limit = 50, offset = 0): Promise { + return this.get(`/api/automation/v1?${paginationQuery(limit, offset)}`); + } + + getAutomation(id: string): Promise { + return this.get(`/api/automation/v1/${encodeURIComponent(id)}`); + } + + updateAutomation(id: string, body: unknown): Promise { + return this.patch(`/api/automation/v1/${encodeURIComponent(id)}`, body); + } + + async deleteAutomation(id: string): Promise { + await this.delete(`/api/automation/v1/${encodeURIComponent(id)}`); + } + + dispatchAutomation(id: string): Promise { + return this.post(`/api/automation/v1/${encodeURIComponent(id)}/dispatch`); + } + + listAutomationRuns(id: string, limit = 50, offset = 0): Promise { + return this.get( + `/api/automation/v1/${encodeURIComponent(id)}/runs?${paginationQuery(limit, offset)}` + ); + } + + downloadAutomationTarball(id: string): Promise { + return this.get(`/api/automation/v1/${encodeURIComponent(id)}/tarball`, { + responseType: 'blob', + }); + } + + getAutomationHealth(): Promise { + return this.get('/api/automation/health', { timeoutSeconds: 5 }); + } + + private profileBasePath(): string { + return this.orgId + ? `/api/organizations/${encodeURIComponent(this.orgId)}/profiles` + : SETTINGS_PROFILES_PATH; + } + + private buildUpstreamAuthHeaders(options: CloudRequestOptions): Record { + const mode = options.authMode ?? 'bearer'; + if (mode === 'none') return {}; + if (mode === 'session-api-key') { + return options.sessionApiKey ? { 'X-Session-API-Key': options.sessionApiKey } : {}; + } + if (!this.apiKey) return {}; + return { Authorization: `Bearer ${this.apiKey}` }; + } + + private async requestDirect(options: CloudRequestOptions): Promise { + const headers = { + ...this.buildUpstreamAuthHeaders(options), + ...(this.orgId ? { 'X-Org-Id': this.orgId } : {}), + ...(options.headers ?? {}), + }; + return fetchAndParse({ + host: this.host, + method: options.method, + path: options.path, + params: options.params, + body: options.body, + headers, + timeoutMs: options.timeoutSeconds ? options.timeoutSeconds * 1000 : this.timeout, + acceptableStatusCodes: options.acceptableStatusCodes, + responseType: options.responseType, + }); + } + + private async requestThroughProxy(options: CloudRequestOptions): Promise { + if (!this.proxy) { + throw new Error('CloudClient proxy options are required for hostOverride requests'); + } + + const upstreamHeaders = { + ...this.buildUpstreamAuthHeaders(options), + ...(this.orgId ? { 'X-Org-Id': this.orgId } : {}), + ...(options.headers ?? {}), + }; + const proxyHeaders = { + 'Content-Type': 'application/json', + ...(this.proxy.apiKey ? { 'X-Session-API-Key': this.proxy.apiKey } : {}), + ...(this.proxy.headers ?? {}), + }; + const proxyPath = this.proxy.path ?? '/api/cloud-proxy'; + return fetchAndParse({ + host: this.proxy.host, + method: 'POST', + path: proxyPath, + body: { + host: options.hostOverride, + method: options.method, + path: appendParams(options.path, options.params), + headers: upstreamHeaders, + body: options.body ?? null, + ...(options.timeoutSeconds ? { timeout_seconds: options.timeoutSeconds } : {}), + }, + headers: proxyHeaders, + timeoutMs: this.timeout, + acceptableStatusCodes: options.acceptableStatusCodes, + responseType: options.responseType, + }); + } +} + +interface FetchAndParseOptions { + host: string; + method: string; + path: string; + params?: Record; + body?: unknown; + headers?: Record; + timeoutMs: number; + acceptableStatusCodes?: Set; + responseType?: ResponseType; +} + +async function fetchAndParse(options: FetchAndParseOptions): Promise { + const url = `${options.host.replace(/\/+$/, '')}${appendParams(options.path, options.params)}`; + const headers: Record = { + ...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), + ...(options.headers ?? {}), + }; + const init: RequestInit = { + method: options.method, + headers, + signal: AbortSignal.timeout(options.timeoutMs), + }; + if (options.body !== undefined && options.method !== 'GET') { + init.body = options.body instanceof FormData ? options.body : JSON.stringify(options.body); + } + + let response: Response; + try { + response = await fetch(url, init); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Request timeout after ${options.timeoutMs}ms`, { cause: error }); + } + throw error; + } + + const isAcceptable = + options.acceptableStatusCodes?.has(response.status) || + (!options.acceptableStatusCodes && response.ok); + if (!isAcceptable) { + throw await buildHttpError(response); + } + + return parseResponse(response, options.responseType ?? 'auto'); +} + +async function buildHttpError(response: Response): Promise { + let errorContent: unknown; + try { + const contentType = response.headers.get('content-type'); + errorContent = contentType?.includes('application/json') + ? await response.json() + : await response.text(); + } catch { + errorContent = null; + } + return new HttpError( + response.status, + response.statusText, + errorContent, + `HTTP request failed (${response.status} ${response.statusText}): ${JSON.stringify( + errorContent + )}` + ); +} + +async function parseResponse( + response: Response, + responseType: ResponseType +): Promise { + if (response.status === 204) { + return undefined as TResponse; + } + if (responseType === 'blob') return (await response.blob()) as TResponse; + if (responseType === 'arrayBuffer') return (await response.arrayBuffer()) as TResponse; + if (responseType === 'text') return (await response.text()) as TResponse; + + const contentType = response.headers.get('content-type'); + if (responseType === 'json' || contentType?.includes('application/json')) { + return (await response.json()) as TResponse; + } + return (await response.text()) as TResponse; +} + +function appendParams(path: string, params?: Record): string { + if (!params) return path; + const [base, existingQuery = ''] = path.split('?'); + const search = new URLSearchParams(existingQuery); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue; + if (Array.isArray(value)) { + value.forEach((item) => search.append(key, String(item))); + } else { + search.append(key, String(value)); + } + } + const query = search.toString(); + return query ? `${base}?${query}` : base; +} + +function buildQuerySuffix(params: Record): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) search.set(key, String(value)); + } + const query = search.toString(); + return query ? `?${query}` : ''; +} + +function paginationQuery(limit: number, offset: number): string { + const params = new URLSearchParams(); + params.set('limit', String(limit)); + params.set('offset', String(offset)); + return params.toString(); +} + +function deriveAgentSettings(flat: CloudSettingsResponse): Record { + if (flat.agent_settings && Object.keys(flat.agent_settings).length > 0) { + return flat.agent_settings; + } + const agent: Record = {}; + const llm: Record = {}; + if (typeof flat.llm_model === 'string') llm.model = flat.llm_model; + if (typeof flat.llm_base_url === 'string') llm.base_url = flat.llm_base_url; + if (typeof flat.llm_api_key === 'string') llm.api_key = flat.llm_api_key; + if (Object.keys(llm).length > 0) agent.llm = llm; + + const condenser: Record = {}; + if (typeof flat.enable_default_condenser === 'boolean') { + condenser.enabled = flat.enable_default_condenser; + } + if (typeof flat.condenser_max_size === 'number') { + condenser.max_size = flat.condenser_max_size; + } + if (Object.keys(condenser).length > 0) agent.condenser = condenser; + + if (typeof flat.agent === 'string') agent.agent = flat.agent; + if (flat.mcp_config && Object.keys(flat.mcp_config).length > 0) { + agent.mcp_config = flat.mcp_config; + } + return agent; +} + +function deriveConversationSettings( + flat: CloudSettingsResponse +): Record { + if (flat.conversation_settings && Object.keys(flat.conversation_settings).length > 0) { + return flat.conversation_settings; + } + const out: Record = {}; + if (typeof flat.confirmation_mode === 'boolean') { + out.confirmation_mode = flat.confirmation_mode; + } + if (typeof flat.security_analyzer === 'string' || flat.security_analyzer === null) { + out.security_analyzer = flat.security_analyzer; + } + if (typeof flat.max_iterations === 'number') { + out.max_iterations = flat.max_iterations; + } + return out; +} diff --git a/src/client/device-flow-client.ts b/src/client/device-flow-client.ts new file mode 100644 index 00000000..ee9189a9 --- /dev/null +++ b/src/client/device-flow-client.ts @@ -0,0 +1,236 @@ +export class DeviceFlowError extends Error { + constructor( + message: string, + public readonly code?: string + ) { + super(message); + this.name = 'DeviceFlowError'; + Object.setPrototypeOf(this, DeviceFlowError.prototype); + } +} + +export interface DeviceAuthorizationResponse { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + expires_in: number; + interval: number; +} + +export interface DeviceTokenResponse { + access_token: string; + token_type: string; + expires_in?: number; +} + +export interface PollDeviceTokenOptions { + interval: number; + timeout?: number; + signal?: AbortSignal; +} + +interface DeviceTokenErrorResponse { + error: string; + error_description?: string; + interval?: number; +} + +const DEFAULT_TIMEOUT_MS = 600_000; +const MAX_INTERVAL_MS = 30_000; + +export function isOpenHandsCloudHost(host: string): boolean { + try { + const trimmed = host.trim().toLowerCase(); + const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + const hostname = new URL(withProtocol).hostname; + return ( + hostname.endsWith('.all-hands.dev') || + hostname === 'all-hands.dev' || + hostname.endsWith('.openhands.dev') || + hostname === 'openhands.dev' + ); + } catch { + return false; + } +} + +function normalizeHost(host: string): string { + return host.replace(/\/+$/, ''); +} + +async function requestCloudDeviceEndpoint( + host: string, + path: string, + body: unknown, + contentType: string, + signal?: AbortSignal +): Promise { + const requestBody = + typeof body === 'string' || + body instanceof Blob || + body instanceof FormData || + body instanceof URLSearchParams + ? body + : JSON.stringify(body); + + return fetch(`${normalizeHost(host)}${path}`, { + method: 'POST', + headers: { 'Content-Type': contentType }, + body: requestBody, + signal, + }); +} + +export async function startDeviceFlow(host: string): Promise { + try { + const response = await requestCloudDeviceEndpoint( + host, + '/oauth/device/authorize', + {}, + 'application/json' + ); + + if (!response.ok) { + throw new DeviceFlowError(`Failed to start device flow: Server returned ${response.status}`); + } + + const data = await response.json(); + if (!data.device_code || !data.user_code || !data.verification_uri) { + throw new DeviceFlowError( + 'Invalid response from device authorization endpoint: missing required fields' + ); + } + + return { + device_code: data.device_code, + user_code: data.user_code, + verification_uri: data.verification_uri, + verification_uri_complete: + data.verification_uri_complete ?? + `${data.verification_uri}?user_code=${encodeURIComponent(data.user_code)}`, + expires_in: data.expires_in ?? 600, + interval: data.interval ?? 5, + }; + } catch (error) { + if (error instanceof DeviceFlowError) { + throw error; + } + throw new DeviceFlowError( + `Failed to start device flow: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +export async function pollForToken( + host: string, + deviceCode: string, + options: PollDeviceTokenOptions +): Promise { + const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS; + let interval = Math.max(1, options.interval) * 1000; + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + if (options.signal?.aborted) { + throw new DeviceFlowError('Authorization cancelled', 'cancelled'); + } + + try { + const body = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: deviceCode, + }); + const response = await requestCloudDeviceEndpoint( + host, + '/oauth/device/token', + body, + 'application/x-www-form-urlencoded', + options.signal + ); + + if (response.ok) { + const data = await response.json(); + if (!data.access_token) { + throw new DeviceFlowError('Invalid token response: missing access_token'); + } + return { + access_token: data.access_token, + token_type: data.token_type ?? 'Bearer', + expires_in: data.expires_in, + }; + } + + let errorData: DeviceTokenErrorResponse; + try { + errorData = await response.json(); + } catch { + throw new DeviceFlowError(`Unexpected response from server: ${response.status}`); + } + + switch (errorData.error) { + case 'authorization_pending': + break; + case 'slow_down': + if ( + typeof errorData.interval === 'number' && + Number.isFinite(errorData.interval) && + errorData.interval > 0 + ) { + interval = Math.max(1, Math.min(errorData.interval, 30)) * 1000; + } else { + interval = Math.min(interval + 5000, MAX_INTERVAL_MS); + } + break; + case 'expired_token': + throw new DeviceFlowError('Device code has expired. Please try again.', 'expired_token'); + case 'access_denied': + throw new DeviceFlowError('Authorization request was denied.', 'access_denied'); + default: + throw new DeviceFlowError( + `Authorization error: ${errorData.error}${ + errorData.error_description ? ` - ${errorData.error_description}` : '' + }`, + errorData.error + ); + } + } catch (error) { + if (error instanceof DeviceFlowError) { + throw error; + } + if (error instanceof DOMException && error.name === 'AbortError') { + throw new DeviceFlowError('Authorization cancelled', 'cancelled'); + } + console.warn('Network error during polling, retrying:', error); + } + + try { + await sleep(interval, options.signal); + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new DeviceFlowError('Authorization cancelled', 'cancelled'); + } + throw error; + } + } + + throw new DeviceFlowError('Timeout waiting for authorization. Please try again.', 'timeout'); +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException('Aborted', 'AbortError')); + return; + } + const timeoutId = setTimeout(resolve, ms); + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timeoutId); + reject(new DOMException('Aborted', 'AbortError')); + }, + { once: true } + ); + }); +} diff --git a/src/client/openhands-client.ts b/src/client/openhands-client.ts new file mode 100644 index 00000000..91313209 --- /dev/null +++ b/src/client/openhands-client.ts @@ -0,0 +1,208 @@ +import { AgentProfilesClient } from './agent-profiles-client'; +import { BashClient } from './bash-client'; +import { ConversationClient } from './conversation-client'; +import { DesktopClient } from './desktop-client'; +import { FileClient } from './file-client'; +import { HooksClient } from './hooks-client'; +import { HttpClient, type ResponseType } from './http-client'; +import { LLMMetadataClient } from './llm-client'; +import { MCPClient } from './mcp-client'; +import { MetaProfilesClient } from './meta-profiles-client'; +import { PluginsClient } from './plugins-client'; +import { ProfilesClient } from './profiles-client'; +import { ServerClient } from './server-client'; +import { SettingsClient } from './settings-client'; +import { SharedClient } from './shared-client'; +import { SkillsClient } from './skills-client'; +import { SubAgentsClient } from './sub-agents-client'; +import { ToolClient } from './tool-client'; +import { VSCodeClient } from './vscode-client'; +import { WorkspacesClient } from './workspaces-client'; + +export type OpenHandsClientKind = 'agent-server' | 'cloud'; + +export type OpenHandsRequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + +export type OpenHandsRequestAuthMode = 'default' | 'bearer' | 'session-api-key' | 'none'; + +export interface OpenHandsClientOptions { + host: string; + apiKey?: string; + timeout?: number; +} + +export interface OpenHandsRequestOptions { + method: OpenHandsRequestMethod; + path: string; + params?: Record; + body?: unknown; + headers?: Record; + timeoutSeconds?: number; + acceptableStatusCodes?: Set; + responseType?: ResponseType; + /** + * Optional alternate upstream host. Cloud clients use this for runtime + * sandbox calls that must be proxied through an agent-server endpoint. + * Agent-server clients use it to target a per-conversation runtime URL. + */ + hostOverride?: string; + /** + * Overrides the default auth strategy for this request. Agent-server calls + * default to `X-Session-API-Key`; cloud app calls default to bearer auth. + */ + authMode?: OpenHandsRequestAuthMode; + /** API key to use when `authMode` is `session-api-key`. */ + sessionApiKey?: string | null; +} + +export abstract class OpenHandsClient { + public readonly host: string; + public readonly apiKey?: string; + protected readonly timeout: number; + + constructor(options: OpenHandsClientOptions) { + this.host = options.host.replace(/\/+$/, ''); + this.apiKey = options.apiKey; + this.timeout = options.timeout || 60000; + } + + abstract readonly kind: OpenHandsClientKind; + + abstract request(options: OpenHandsRequestOptions): Promise; + + get( + path: string, + options: Omit = {} + ): Promise { + return this.request({ ...options, method: 'GET', path }); + } + + post( + path: string, + body?: unknown, + options: Omit = {} + ): Promise { + return this.request({ ...options, method: 'POST', path, body }); + } + + patch( + path: string, + body?: unknown, + options: Omit = {} + ): Promise { + return this.request({ ...options, method: 'PATCH', path, body }); + } + + put( + path: string, + body?: unknown, + options: Omit = {} + ): Promise { + return this.request({ ...options, method: 'PUT', path, body }); + } + + delete( + path: string, + options: Omit = {} + ): Promise { + return this.request({ ...options, method: 'DELETE', path }); + } + + close(): void { + // Implemented by concrete clients when they own resources. + } +} + +export class AgentServerClient extends OpenHandsClient { + readonly kind = 'agent-server' as const; + + readonly server: ServerClient; + readonly conversations: ConversationClient; + readonly files: FileClient; + readonly bash: BashClient; + readonly settings: SettingsClient; + readonly profiles: ProfilesClient; + readonly agentProfiles: AgentProfilesClient; + readonly metaProfiles: MetaProfilesClient; + readonly skills: SkillsClient; + readonly subAgents: SubAgentsClient; + readonly hooks: HooksClient; + readonly mcp: MCPClient; + readonly plugins: PluginsClient; + readonly tools: ToolClient; + readonly vscode: VSCodeClient; + readonly desktop: DesktopClient; + readonly shared: SharedClient; + readonly llm: LLMMetadataClient; + readonly workspaces: WorkspacesClient; + + private readonly client: HttpClient; + + constructor(options: OpenHandsClientOptions) { + super(options); + const clientOptions = { + host: this.host, + apiKey: this.apiKey, + timeout: this.timeout, + }; + + this.client = new HttpClient({ + baseUrl: this.host, + apiKey: this.apiKey, + timeout: this.timeout, + }); + this.server = new ServerClient(clientOptions); + this.conversations = new ConversationClient(clientOptions); + this.files = new FileClient(clientOptions); + this.bash = new BashClient(clientOptions); + this.settings = new SettingsClient(clientOptions); + this.profiles = new ProfilesClient(clientOptions); + this.agentProfiles = new AgentProfilesClient(clientOptions); + this.metaProfiles = new MetaProfilesClient(clientOptions); + this.skills = new SkillsClient(clientOptions); + this.subAgents = new SubAgentsClient(clientOptions); + this.hooks = new HooksClient(clientOptions); + this.mcp = new MCPClient(clientOptions); + this.plugins = new PluginsClient(clientOptions); + this.tools = new ToolClient(clientOptions); + this.vscode = new VSCodeClient(clientOptions); + this.desktop = new DesktopClient(clientOptions); + this.shared = new SharedClient(clientOptions); + this.llm = new LLMMetadataClient(clientOptions); + this.workspaces = new WorkspacesClient(clientOptions); + } + + async request(options: OpenHandsRequestOptions): Promise { + const host = options.hostOverride?.replace(/\/+$/, '') ?? this.host; + const apiKey = + options.authMode === 'none' + ? undefined + : options.authMode === 'session-api-key' + ? (options.sessionApiKey ?? undefined) + : this.apiKey; + const client = + host === this.host && apiKey === this.apiKey + ? this.client + : new HttpClient({ + baseUrl: host, + apiKey, + timeout: this.timeout, + }); + + const response = await client.request({ + method: options.method, + url: options.path, + params: options.params, + data: options.body, + headers: options.headers, + timeout: options.timeoutSeconds ? options.timeoutSeconds * 1000 : this.timeout, + acceptableStatusCodes: options.acceptableStatusCodes, + responseType: options.responseType, + }); + return response.data; + } + + override close(): void { + this.client.close(); + } +} diff --git a/src/clients.ts b/src/clients.ts index 339a2e9e..ba9f221f 100644 --- a/src/clients.ts +++ b/src/clients.ts @@ -17,6 +17,14 @@ export { VSCodeClient } from './client/vscode-client'; export { DesktopClient } from './client/desktop-client'; export { SharedClient } from './client/shared-client'; export { WorkspacesClient } from './client/workspaces-client'; +export { AgentServerClient, OpenHandsClient } from './client/openhands-client'; +export { CloudClient } from './client/cloud-client'; +export { + DeviceFlowError, + isOpenHandsCloudHost, + pollForToken, + startDeviceFlow, +} from './client/device-flow-client'; export { AGENT_SERVER_VERSION_ERROR_CODE, AgentServerFeatureRequirements, @@ -73,3 +81,43 @@ export type { WorkspaceParentItem, } from './client/workspaces-client'; export type { AgentServerFeatureRequirement } from './client/agent-server-compatibility'; +export type { + OpenHandsClientKind, + OpenHandsClientOptions, + OpenHandsRequestAuthMode, + OpenHandsRequestMethod, + OpenHandsRequestOptions, +} from './client/openhands-client'; +export type { + CloudApiKeyMetadata, + CloudAppConversation, + CloudBranchPage, + CloudClientOptions, + CloudConversationPage, + CloudConversationStartRequest, + CloudConversationStartTask, + CloudGitBranch, + CloudGitRepository, + CloudInstallationPage, + CloudOrganization, + CloudOrganizationMe, + CloudOrganizationsResponse, + CloudOrganizationsResult, + CloudPage, + CloudProxyOptions, + CloudRepositoryPage, + CloudRequestOptions, + CloudSandboxInfo, + CloudSecret, + CloudSecretWithoutValue, + CloudSettingsResponse, + CloudSettingsValue, + CloudSkillInfo, + CloudSuggestedTask, + SaveCloudSettingsRequest, +} from './client/cloud-client'; +export type { + DeviceAuthorizationResponse, + DeviceTokenResponse, + PollDeviceTokenOptions, +} from './client/device-flow-client'; From 62a212779e645cd8820e6b196c87d85b59add45d Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 10 Jul 2026 11:38:54 +0000 Subject: [PATCH 2/5] ci: refresh ACP validation context From 95495399f14a766e6364b2619be4a3d43f011342 Mon Sep 17 00:00:00 2001 From: neubig Date: Mon, 13 Jul 2026 06:33:57 +0000 Subject: [PATCH 3/5] test: add aggregate client live evidence harness Co-authored-by: openhands --- .pr/live_aggregate_clients.mjs | 175 +++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 .pr/live_aggregate_clients.mjs diff --git a/.pr/live_aggregate_clients.mjs b/.pr/live_aggregate_clients.mjs new file mode 100644 index 00000000..168e6feb --- /dev/null +++ b/.pr/live_aggregate_clients.mjs @@ -0,0 +1,175 @@ +import { pathToFileURL } from 'node:url'; + +const clientsModule = process.env.CLIENTS_MODULE; +const agentServerHost = process.env.AGENT_SERVER_URL ?? 'http://127.0.0.1:8010'; +const cloudHost = process.env.CLOUD_HOST ?? 'https://app.all-hands.dev'; +const cloudApiKey = process.env.CLOUD_API_KEY; + +if (!clientsModule) { + throw new Error('CLIENTS_MODULE must point to the built clients.js under test'); +} + +const traces = []; +const realFetch = globalThis.fetch; +globalThis.fetch = async (input, init = {}) => { + const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url); + const method = init.method ?? (input instanceof Request ? input.method : 'GET'); + const sanitizedPath = url.pathname.replace(/^\/api\/keys\/\d+$/, '/api/keys/{id}'); + const started = performance.now(); + try { + const response = await realFetch(input, init); + traces.push({ + method, + host: url.host, + path: sanitizedPath, + status: response.status, + elapsed_ms: Number((performance.now() - started).toFixed(1)), + }); + return response; + } catch (error) { + traces.push({ + method, + host: url.host, + path: sanitizedPath, + error: error instanceof Error ? error.name : typeof error, + elapsed_ms: Number((performance.now() - started).toFixed(1)), + }); + throw error; + } +}; + +const clients = await import(pathToFileURL(clientsModule).href); +const exportAvailability = Object.fromEntries( + ['OpenHandsClient', 'AgentServerClient', 'CloudClient', 'startDeviceFlow', 'pollForToken'].map( + (name) => [name, typeof clients[name] !== 'undefined'] + ) +); + +const legacyServer = new clients.ServerClient({ host: agentServerHost, timeout: 5000 }); +const legacyBash = new clients.BashClient({ host: agentServerHost, timeout: 5000 }); +const legacyHealth = await legacyServer.getHealth(); +const legacyInfo = await legacyServer.getServerInfo(); +const legacyBashOutput = await legacyBash.executeCommand('printf legacy-endpoint-ok', '/workspace', 5); + +const result = { + commit: process.env.TESTED_COMMIT ?? null, + module: clientsModule, + exports: exportAvailability, + legacy: { + health: legacyHealth.status, + serverVersion: legacyInfo.version, + bashExitCode: legacyBashOutput.exit_code, + bashStdout: legacyBashOutput.stdout, + }, +}; + +if (clients.AgentServerClient) { + const aggregate = new clients.AgentServerClient({ host: agentServerHost, timeout: 5000 }); + const namespacedHealth = await aggregate.server.getHealth(); + const directHealth = await aggregate.get('/health'); + const aggregateBashOutput = await aggregate.bash.executeCommand( + 'printf aggregate-endpoint-ok', + '/workspace', + 5 + ); + let transportError; + try { + const unavailable = new clients.AgentServerClient({ + host: 'http://127.0.0.1:9', + timeout: 250, + }); + await unavailable.get('/health'); + } catch (error) { + transportError = { + name: error instanceof Error ? error.name : typeof error, + messageHasRequestFailure: + error instanceof Error && /request failed|fetch failed|timeout/i.test(error.message), + }; + } + result.aggregateAgentServer = { + kind: aggregate.kind, + namespacedHealth: namespacedHealth.status, + directHealth: directHealth.status, + bashExitCode: aggregateBashOutput.exit_code, + bashStdout: aggregateBashOutput.stdout, + transportError, + }; + aggregate.close(); +} + +if (clients.CloudClient && cloudApiKey) { + const cloud = new clients.CloudClient({ host: cloudHost, apiKey: cloudApiKey, timeout: 10000 }); + const currentKey = await cloud.getCurrentApiKey(); + const organizations = await cloud.getOrganizations(); + const settings = await cloud.getSettingsWithDerivedFields(); + let httpError; + try { + await cloud.post('/api/keys/current'); + } catch (error) { + httpError = { + name: error instanceof Error ? error.name : typeof error, + status: typeof error?.status === 'number' ? error.status : null, + }; + } + result.aggregateCloud = { + kind: cloud.kind, + authTypePresent: typeof currentKey.auth_type === 'string' && currentKey.auth_type.length > 0, + orgIdPresent: typeof currentKey.org_id === 'string' && currentKey.org_id.length > 0, + organizationCount: organizations.items.length, + currentOrgPresent: typeof organizations.currentOrgId === 'string', + agentSettingsDerived: typeof settings.agent_settings === 'object', + conversationSettingsDerived: typeof settings.conversation_settings === 'object', + httpError, + }; + + const device = await cloud.startDeviceFlow(); + const verification = await fetch(`${cloudHost}/oauth/device/verify-authenticated`, { + method: 'POST', + headers: { + Authorization: `Bearer ${cloudApiKey}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ user_code: device.user_code }), + }); + if (!verification.ok) { + throw new Error(`Device verification failed with HTTP ${verification.status}`); + } + const linkedToken = await cloud.pollForToken(device.device_code, { + interval: device.interval, + timeout: 15000, + }); + const linkedCloud = new clients.CloudClient({ + host: cloudHost, + apiKey: linkedToken.access_token, + timeout: 10000, + }); + const linkedKey = await linkedCloud.getCurrentApiKey(); + await linkedCloud.delete(`/api/keys/${linkedKey.id}`); + let deletedTokenStatus; + try { + await linkedCloud.getCurrentApiKey(); + } catch (error) { + deletedTokenStatus = typeof error?.status === 'number' ? error.status : null; + } + result.deviceFlow = { + startSucceeded: true, + verificationHost: new URL(device.verification_uri).host, + completeUriPresent: typeof device.verification_uri_complete === 'string', + expiresIn: device.expires_in, + interval: device.interval, + authenticatedVerificationStatus: verification.status, + pollReturnedBearer: linkedToken.token_type === 'Bearer', + returnedKeyRecognized: + typeof linkedKey.id === 'number' && + typeof linkedKey.user_id === 'string' && + linkedKey.user_id.length > 0, + returnedKeyCleanedUp: deletedTokenStatus === 401, + }; + linkedCloud.close(); + cloud.close(); +} + +legacyServer.close(); +legacyBash.close(); +result.traces = traces; +console.log(JSON.stringify(result, null, 2)); From 4588056d6c6432ffdd4663d54c4ab9c0424692eb Mon Sep 17 00:00:00 2001 From: hieptl Date: Thu, 16 Jul 2026 14:08:27 +0700 Subject: [PATCH 4/5] fix: normalize TimeoutError-based fetch timeouts in CloudClient --- README.md | 43 ++++++++++++++++++++++++++++++++++ src/client/cloud-client.ts | 7 +++++- src/client/openhands-client.ts | 15 ++++++++---- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index bd330a01..ff242b8f 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,49 @@ If you need the lower-level endpoint-specific clients directly, import them from import { ServerClient, BashClient } from '@openhands/typescript-client/clients'; ``` +### Aggregate Agent Server and Cloud Clients + +The `/clients` entrypoint also exposes aggregate clients. `AgentServerClient` bundles every agent-server endpoint client behind namespaces, and `CloudClient` covers the OpenHands Cloud app API (bearer auth, org scoping, and an optional proxy for runtime-sandbox calls): + +```typescript +import { AgentServerClient, CloudClient } from '@openhands/typescript-client/clients'; + +const agentServer = new AgentServerClient({ + host: 'http://localhost:3000', + apiKey: 'your-session-api-key', +}); +const health = await agentServer.server.getHealth(); +const conversations = await agentServer.conversations.searchConversations(); + +const cloud = new CloudClient({ + host: 'https://app.all-hands.dev', + apiKey: 'your-cloud-api-key', + orgId: 'optional-org-id', // sent as X-Org-Id on every request + // Optional: requests with `hostOverride` (runtime-sandbox calls) are + // routed through this agent-server's /api/cloud-proxy endpoint instead + // of being sent to the host directly. + proxy: { host: 'http://localhost:3000', apiKey: 'your-session-api-key' }, +}); +const orgs = await cloud.getOrganizations(); +const created = await cloud.createConversation({ initial_message: 'Fix the bug' }); +``` + +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'); +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, +}); +const cloud = new CloudClient({ + host: 'https://app.all-hands.dev', + apiKey: token.access_token, +}); +``` + ### Working with Events ```typescript diff --git a/src/client/cloud-client.ts b/src/client/cloud-client.ts index bba47054..0f4f6cf4 100644 --- a/src/client/cloud-client.ts +++ b/src/client/cloud-client.ts @@ -727,7 +727,12 @@ async function fetchAndParse(options: FetchAndParseOptions): Promise< try { response = await fetch(url, init); } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { + // AbortSignal.timeout() aborts with a TimeoutError; some runtimes + // surface plain AbortError instead. + if ( + error instanceof Error && + (error.name === 'TimeoutError' || error.name === 'AbortError') + ) { throw new Error(`Request timeout after ${options.timeoutMs}ms`, { cause: error }); } throw error; diff --git a/src/client/openhands-client.ts b/src/client/openhands-client.ts index 91313209..d01e1ba3 100644 --- a/src/client/openhands-client.ts +++ b/src/client/openhands-client.ts @@ -41,14 +41,19 @@ export interface OpenHandsRequestOptions { acceptableStatusCodes?: Set; responseType?: ResponseType; /** - * Optional alternate upstream host. Cloud clients use this for runtime - * sandbox calls that must be proxied through an agent-server endpoint. - * Agent-server clients use it to target a per-conversation runtime URL. + * Optional alternate upstream host. Semantics differ by client kind: + * agent-server clients send the request directly to this host (e.g. a + * per-conversation runtime URL), while cloud clients route it through + * the configured proxy endpoint (`CloudClientOptions.proxy`, default + * path `/api/cloud-proxy`) with this host in the proxy envelope. */ hostOverride?: string; /** - * Overrides the default auth strategy for this request. Agent-server calls - * default to `X-Session-API-Key`; cloud app calls default to bearer auth. + * Overrides the default auth strategy for this request. `default` uses + * the client's own scheme: agent-server clients send the client API key + * as `X-Session-API-Key` (they treat `bearer` the same way), while cloud + * clients send it as `Authorization: Bearer`. `session-api-key` sends + * `sessionApiKey` as `X-Session-API-Key`; `none` sends no auth header. */ authMode?: OpenHandsRequestAuthMode; /** API key to use when `authMode` is `session-api-key`. */ From ed226fe8891e1843c3f893dcc156d2e61279c4dc Mon Sep 17 00:00:00 2001 From: hieptl Date: Thu, 16 Jul 2026 14:11:14 +0700 Subject: [PATCH 5/5] fix: lint --- src/client/cloud-client.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/client/cloud-client.ts b/src/client/cloud-client.ts index 0f4f6cf4..eb80b44f 100644 --- a/src/client/cloud-client.ts +++ b/src/client/cloud-client.ts @@ -729,10 +729,7 @@ async function fetchAndParse(options: FetchAndParseOptions): Promise< } catch (error) { // AbortSignal.timeout() aborts with a TimeoutError; some runtimes // surface plain AbortError instead. - if ( - error instanceof Error && - (error.name === 'TimeoutError' || error.name === 'AbortError') - ) { + if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) { throw new Error(`Request timeout after ${options.timeoutMs}ms`, { cause: error }); } throw error;