Skip to content

Commit 8ac89f1

Browse files
authored
Merge pull request #93 from imbue-ai/preston/more-connections
Add Ramp service (browser OAuth PKCE auth + agent-tools API)
2 parents 02a504f + b0e0e7c commit 8ac89f1

8 files changed

Lines changed: 314 additions & 3 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -440,4 +440,4 @@ override `config.json` values.
440440
Latchkey currently offers varying levels of support for the
441441
following services: AWS, Calendly, Discord, Dropbox, Figma, GitHub, GitLab,
442442
Gmail, Google Analytics, Google Calendar, Google Docs, Google Drive, Google Sheets,
443-
Linear, Mailchimp, Notion, Sentry, Slack, Stripe, Telegram, Todoist, Yelp, Zoom, and more.
443+
Linear, Mailchimp, Notion, Ramp, Sentry, Slack, Stripe, Telegram, Todoist, Yelp, Zoom, and more.

skills/generic/latchkey/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ used to see how to provide credentials for a specific service.
106106
Latchkey currently offers varying levels of support for the
107107
following services: AWS, Calendly, Coolify, Discord, Dropbox, Figma, GitHub, GitLab,
108108
Gmail, Google Analytics, Google Calendar, Google Docs, Google Drive, Google Sheets,
109-
Linear, Mailchimp, Notion, Sentry, Slack, Stripe, Telegram, Todoist, Umami, Yelp, Zoom, and more.
109+
Linear, Mailchimp, Notion, Ramp, Sentry, Slack, Stripe, Telegram, Todoist, Umami, Yelp, Zoom, and more.
110110

111111
### User-registered services
112112

skills/openclaw/latchkey/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ used to see how to provide credentials for a specific service.
9898
Latchkey currently offers varying levels of support for the
9999
following services: AWS, Calendly, Coolify, Discord, Dropbox, Figma, GitHub, GitLab,
100100
Gmail, Google Analytics, Google Calendar, Google Docs, Google Drive, Google Sheets,
101-
Linear, Mailchimp, Notion, Sentry, Slack, Stripe, Telegram, Todoist, Umami, Yelp, Zoom, and more.
101+
Linear, Mailchimp, Notion, Ramp, Sentry, Slack, Stripe, Telegram, Todoist, Umami, Yelp, Zoom, and more.
102102

103103
### User-registered services
104104

src/serviceRegistry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
GOOGLE_DIRECTIONS,
3434
COOLIFY,
3535
UMAMI,
36+
RAMP,
3637
TODOIST,
3738
} from './services/index.js';
3839

@@ -161,5 +162,6 @@ export const SERVICE_REGISTRY = new ServiceRegistry([
161162
GOOGLE_DIRECTIONS,
162163
COOLIFY,
163164
UMAMI,
165+
RAMP,
164166
TODOIST,
165167
]);

src/services/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,5 @@ export { GoogleDirections, GOOGLE_DIRECTIONS } from './google/directions.js';
4545
export { Yelp, YELP } from './yelp.js';
4646
export { Coolify, COOLIFY } from './coolify.js';
4747
export { Umami, UMAMI } from './umami.js';
48+
export { Ramp, RAMP } from './ramp.js';
4849
export { Todoist, TODOIST } from './todoist.js';

src/services/ramp.ts

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
/**
2+
* Ramp service implementation (browser / AI agent-key pathway, production only).
3+
*
4+
* `latchkey auth browser ramp` runs the OAuth 2.0 authorization-code + PKCE flow
5+
* against Ramp's public client (a fixed client ID, no secret). The hosted consent
6+
* screen (auth_level=auto) mints an "AI agent key"; latchkey catches the loopback
7+
* callback, exchanges the code for a bearer + refresh token at
8+
* `.../developer/v1/token/pkce`, and stores them as OAuthCredentials (auto-refreshed).
9+
*
10+
* Agent keys use the agent-tools endpoints -- POST https://api.ramp.com/developer/v1/
11+
* agent-tools/<tool> with a {"rationale": ...} body (they are auth-level barred from
12+
* the standard REST endpoints). Spec: https://api.ramp.com/v1/public/agent-tools/spec/.
13+
*/
14+
15+
import { randomUUID } from 'node:crypto';
16+
import type { Browser, BrowserContext, Response } from 'playwright';
17+
import { ApiCredentials, OAuthCredentials } from '../apiCredentials/base.js';
18+
import {
19+
exchangeCodeForTokens,
20+
generateCodeChallenge,
21+
generateCodeVerifier,
22+
refreshAccessToken,
23+
startOAuthCallbackServer,
24+
} from '../oauthUtils.js';
25+
import { isBrowserClosedError, LoginCancelledError, Service, ServiceSession } from './core/base.js';
26+
27+
/** Ramp's public OAuth client (PKCE, no secret), from ramp-cli. */
28+
const RAMP_OAUTH_CLIENT_ID = 'ramp_id_6pKvd0IR3d8Kuzp82SV6YgpVCZOlz68Px6s3wVsr';
29+
30+
/** Hosted authorize endpoint (where the user signs in / approves the agent key). */
31+
const RAMP_AUTHORIZE_URL = 'https://app.ramp.com/v1/authorize';
32+
33+
/** PKCE token endpoint (code exchange + refresh). */
34+
const RAMP_PKCE_TOKEN_ENDPOINT = 'https://api.ramp.com/developer/v1/token/pkce';
35+
36+
/** Loopback callback path; matches ramp-cli's `/callback`. */
37+
const RAMP_OAUTH_CALLBACK_PATH = '/callback';
38+
39+
/** Time to wait for the user to finish the hosted login + agent-key approval. */
40+
const RAMP_LOGIN_TIMEOUT_MS = 300_000;
41+
42+
/**
43+
* Scopes requested on the authorize URL: exactly the scopes Ramp's agent-tools
44+
* OpenAPI declares (no regular-REST-only scopes -- agent keys can't use the standard
45+
* REST API anyway). Ramp grants only the subset the signed-in user is entitled to
46+
* (returned in the token's `scope`), so over-requesting is harmless, but omitting a
47+
* scope an endpoint needs fails at call time with DEVELOPER_7100.
48+
*/
49+
const RAMP_OAUTH_SCOPES = [
50+
'accounting:read',
51+
'ai_spend:read',
52+
'approvals:write',
53+
'bills:read',
54+
'cards:read_agentic',
55+
'cards:write',
56+
'comments:write',
57+
'funds:write',
58+
'limits:read',
59+
'limits:write',
60+
'memos:read',
61+
'purchase_orders:read',
62+
'receipts:write',
63+
'reimbursements:read',
64+
'reimbursements:write',
65+
'tasks:read',
66+
'transactions:read',
67+
'transactions:write',
68+
'treasury:read',
69+
'trips:read',
70+
'trips:write',
71+
'unified_requests:read',
72+
'users:read',
73+
'vendors:read',
74+
'vendors:write',
75+
'x402:write',
76+
].join(' ');
77+
78+
/**
79+
* Browser login session: runs the OAuth authorization-code + PKCE flow in a
80+
* Playwright browser and returns OAuthCredentials. login() is overridden wholesale
81+
* (the base template's static loginUrl + response-watching model doesn't fit a
82+
* per-session authorize URL with a localhost callback), mirroring NotionMcpSession.
83+
*/
84+
class RampOAuthServiceSession extends ServiceSession {
85+
onResponse(_response: Response): void {
86+
// Not used -- login completion is signalled by the OAuth callback, not by
87+
// inspecting page responses.
88+
}
89+
90+
protected isLoginComplete(): boolean {
91+
// Not used -- login() is overridden entirely.
92+
return false;
93+
}
94+
95+
protected finalizeCredentials(
96+
_browser: Browser,
97+
_context: BrowserContext,
98+
_oldCredentials?: ApiCredentials
99+
): Promise<ApiCredentials | null> {
100+
// Not used -- login() is overridden entirely.
101+
return Promise.resolve(null);
102+
}
103+
104+
override async login(
105+
encryptedStorage: import('../encryptedStorage.js').EncryptedStorage,
106+
launchOptions: import('../playwrightUtils.js').BrowserLaunchOptions = {},
107+
_oldCredentials?: ApiCredentials
108+
): Promise<ApiCredentials> {
109+
const { withTempBrowserContext } = await import('../playwrightUtils.js');
110+
const clientId = RAMP_OAUTH_CLIENT_ID;
111+
112+
return withTempBrowserContext(encryptedStorage, launchOptions, async ({ context }) => {
113+
const page = await context.newPage();
114+
115+
const abortController = new AbortController();
116+
const closeHandler = () => {
117+
abortController.abort();
118+
};
119+
page.on('close', closeHandler);
120+
context.on('close', closeHandler);
121+
122+
try {
123+
// 1. Stand up the localhost callback server (random port; Ramp's public
124+
// client allows arbitrary loopback ports per RFC 8252).
125+
const { port, codePromise } = await startOAuthCallbackServer(
126+
RAMP_LOGIN_TIMEOUT_MS,
127+
abortController.signal,
128+
RAMP_OAUTH_CALLBACK_PATH
129+
);
130+
const redirectUri = `http://localhost:${port.toString()}${RAMP_OAUTH_CALLBACK_PATH}`;
131+
132+
// 2. PKCE verifier/challenge.
133+
const codeVerifier = generateCodeVerifier();
134+
const codeChallenge = generateCodeChallenge(codeVerifier);
135+
136+
// 3. Open Ramp's hosted authorize screen. auth_level=auto triggers the
137+
// "create/approve an AI agent key" prompt.
138+
const authUrl = new URL(RAMP_AUTHORIZE_URL);
139+
authUrl.searchParams.set('response_type', 'code');
140+
authUrl.searchParams.set('client_id', clientId);
141+
authUrl.searchParams.set('redirect_uri', redirectUri);
142+
authUrl.searchParams.set('scope', RAMP_OAUTH_SCOPES);
143+
authUrl.searchParams.set('state', randomUUID());
144+
authUrl.searchParams.set('code_challenge', codeChallenge);
145+
authUrl.searchParams.set('code_challenge_method', 'S256');
146+
authUrl.searchParams.set('auth_level', 'auto');
147+
148+
await page.goto(authUrl.toString());
149+
150+
// 4. Wait for the user to finish and the callback to deliver the code.
151+
const code = await codePromise;
152+
153+
// 5. Exchange the code for tokens (public client: no secret).
154+
const tokens = exchangeCodeForTokens(
155+
RAMP_PKCE_TOKEN_ENDPOINT,
156+
code,
157+
clientId,
158+
'',
159+
redirectUri,
160+
codeVerifier
161+
);
162+
const accessTokenExpiresAt = new Date(Date.now() + tokens.expires_in * 1000).toISOString();
163+
164+
await page.close();
165+
166+
// Public client: clientSecret is stored as '' so refresh sends client_id only.
167+
return new OAuthCredentials(
168+
clientId,
169+
'',
170+
tokens.access_token,
171+
tokens.refresh_token,
172+
accessTokenExpiresAt
173+
);
174+
} catch (error: unknown) {
175+
if (error instanceof Error && isBrowserClosedError(error)) {
176+
throw new LoginCancelledError();
177+
}
178+
throw error;
179+
} finally {
180+
page.off('close', closeHandler);
181+
context.off('close', closeHandler);
182+
}
183+
});
184+
}
185+
}
186+
187+
export class Ramp extends Service {
188+
readonly name = 'ramp';
189+
readonly displayName = 'Ramp';
190+
readonly baseApiUrls = ['https://api.ramp.com/'] as const;
191+
readonly loginUrl = 'https://app.ramp.com/';
192+
readonly info =
193+
'Ramp agent-tools API; the REST API is not supported. ' +
194+
'Docs: https://api.ramp.com/v1/public/agent-tools/spec/.';
195+
196+
// Validate credentials against `search-help-center-snippets`: the one agent-tools
197+
// endpoint that requires only a valid token and no specific scope (`security:
198+
// [{oauth2: []}]` in the spec), so the check works regardless of which scopes the
199+
// signed-in user's agent key was granted. It's a POST taking a required
200+
// {query, rationale} body; a bad token returns a non-200 (404 DEVELOPER_7002).
201+
readonly credentialCheckCurlArguments = [
202+
'-X',
203+
'POST',
204+
'-H',
205+
'Content-Type: application/json',
206+
'-d',
207+
'{"query":"ping","rationale":"latchkey credential check"}',
208+
'https://api.ramp.com/developer/v1/agent-tools/search-help-center-snippets',
209+
] as const;
210+
211+
setCredentialsExample(serviceName: string): string {
212+
return `latchkey auth browser ${serviceName}`;
213+
}
214+
215+
/**
216+
* Browser login: run the OAuth authorization-code + PKCE flow and store the
217+
* resulting bearer + refresh token.
218+
*/
219+
override getSession(appNamePrefix: string): RampOAuthServiceSession {
220+
return new RampOAuthServiceSession(this, appNamePrefix);
221+
}
222+
223+
override refreshCredentials(apiCredentials: ApiCredentials): Promise<ApiCredentials | null> {
224+
// Refresh the PKCE access token with the (rotating) refresh token against the
225+
// `/token/pkce` endpoint, mirroring ramp-cli.
226+
if (!(apiCredentials instanceof OAuthCredentials)) {
227+
return Promise.resolve(null);
228+
}
229+
if (apiCredentials.refreshToken === undefined || apiCredentials.refreshToken === '') {
230+
return Promise.resolve(null);
231+
}
232+
const tokens = refreshAccessToken(
233+
RAMP_PKCE_TOKEN_ENDPOINT,
234+
apiCredentials.refreshToken,
235+
apiCredentials.clientId,
236+
apiCredentials.clientSecret
237+
);
238+
if (tokens === null) {
239+
return Promise.resolve(null);
240+
}
241+
const accessTokenExpiresAt = new Date(Date.now() + tokens.expires_in * 1000).toISOString();
242+
// Ramp rotates refresh tokens; keep the old one only if none came back.
243+
return Promise.resolve(
244+
new OAuthCredentials(
245+
apiCredentials.clientId,
246+
apiCredentials.clientSecret,
247+
tokens.access_token,
248+
tokens.refresh_token ?? apiCredentials.refreshToken,
249+
accessTokenExpiresAt,
250+
apiCredentials.refreshTokenExpiresAt
251+
)
252+
);
253+
}
254+
}
255+
256+
export const RAMP = new Ramp();

tests/ramp-session.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* Tests for Ramp's browser-login (OAuth PKCE) session and the credential
3+
* handling that supports it.
4+
*
5+
* These are intentionally network-free: they only exercise the synchronous
6+
* branches (no token mint, no refresh HTTP call, no browser). The live OAuth
7+
* authorization-code + PKCE flow against Ramp's servers is validated manually.
8+
*/
9+
10+
import { describe, it, expect } from 'vitest';
11+
import { RAMP } from '../src/services/ramp.js';
12+
import { AuthorizationBearer, OAuthCredentials } from '../src/apiCredentials/base.js';
13+
import { ServiceSession } from '../src/services/core/base.js';
14+
15+
const FUTURE = new Date(Date.now() + 60 * 60 * 1000).toISOString();
16+
17+
describe('Ramp.getSession (browser login)', () => {
18+
it('returns the OAuth (PKCE) browser-login session', () => {
19+
const session = RAMP.getSession('latchkey');
20+
expect(session).toBeInstanceOf(ServiceSession);
21+
expect(session.constructor.name).toBe('RampOAuthServiceSession');
22+
});
23+
24+
it('does not require a prepare() step (browser login needs no pre-set credentials)', () => {
25+
const session = RAMP.getSession('latchkey');
26+
// authBrowser only demands credentials up front when prepare() is defined.
27+
// Cast away the method type so the unbound-method lint rule doesn't fire on
28+
// this presence check.
29+
expect((session as { prepare?: unknown }).prepare).toBeUndefined();
30+
});
31+
});
32+
33+
describe('Ramp.info', () => {
34+
it('points to the agent-tools spec and notes the regular REST API is unsupported', () => {
35+
expect(RAMP.info).toContain('https://api.ramp.com/v1/public/agent-tools/spec/');
36+
expect(RAMP.info).toContain('not supported');
37+
});
38+
});
39+
40+
describe('Ramp.refreshCredentials with OAuth (browser-login) credentials', () => {
41+
it('returns null when there is no refresh token (cannot refresh offline)', async () => {
42+
const creds = new OAuthCredentials('ramp_id_test', '', 'access-token', undefined, FUTURE);
43+
expect(await RAMP.refreshCredentials(creds)).toBeNull();
44+
});
45+
46+
it('returns null for unrelated credential types', async () => {
47+
expect(await RAMP.refreshCredentials(new AuthorizationBearer('tok'))).toBeNull();
48+
});
49+
});

tests/serviceRegistry.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
GITLAB,
2525
AWS,
2626
TELEGRAM,
27+
RAMP,
2728
TODOIST,
2829
} from '../src/services/index.js';
2930

@@ -45,6 +46,7 @@ describe('ServiceRegistry', () => {
4546
['mailchimp', MAILCHIMP],
4647
['aws', AWS],
4748
['telegram', TELEGRAM],
49+
['ramp', RAMP],
4850
['todoist', TODOIST],
4951
] as const;
5052

@@ -81,6 +83,7 @@ describe('ServiceRegistry', () => {
8183
['https://us1.api.mailchimp.com/3.0/lists', MAILCHIMP],
8284
['https://sts.amazonaws.com/?Action=GetCallerIdentity', AWS],
8385
['https://s3.us-east-1.amazonaws.com/my-bucket', AWS],
86+
['https://api.ramp.com/developer/v1/transactions', RAMP],
8487
['https://api.todoist.com/api/v1/projects', TODOIST],
8588
] as const;
8689

0 commit comments

Comments
 (0)