Skip to content
This repository was archived by the owner on Sep 7, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions .pr/live_aggregate_clients.mjs
Original file line number Diff line number Diff line change
@@ -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));
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion scripts/rewrite-relative-imports.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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;
}
);

Expand Down
114 changes: 114 additions & 0 deletions src/__tests__/api-clients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,18 @@ import {
} from '../index';
import {
AgentProfilesClient,
AgentServerClient,
AgentServerVersionError,
BashClient,
clearAgentServerInfoCache,
CloudClient,
compareAgentServerVersions,
ConversationClient,
DeviceFlowError,
FileClient,
HooksClient,
isAgentServerVersionError,
isOpenHandsCloudHost,
LLMMetadataClient,
MCPClient,
MetaProfilesClient,
Expand Down Expand Up @@ -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 = {
Expand Down
Loading
Loading