Skip to content

Commit 016cb77

Browse files
pseay-imbueSculptorclaude
committed
Simplify Ramp to the agent-key pathway only
Drop the standard-REST (client_credentials / `set-nocurl`) pathway and keep only the browser OAuth+PKCE flow that mints an AI agent key for the agent-tools API. Agent keys are auth-level barred from the standard REST endpoints, so the REST credential type was never usable from a minds agent anyway. Removed: RampCredentials (+ schema/serialization registration), requestRampToken (the `/developer/v1/token` client_credentials grant), the getCredentialsNoCurl override (falls back to the base "not supported" default), and the unused client_credentials token-check plumbing. Credentials are now exclusively OAuthCredentials, validated by holding/refreshing a live token. Co-authored-by: Sculptor <sculptor@imbue.com> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3645a80 commit 016cb77

4 files changed

Lines changed: 28 additions & 366 deletions

File tree

src/apiCredentials/serialization.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import {
2020
} from './base.js';
2121
import { AwsCredentials, AwsCredentialsSchema } from '../services/aws.js';
2222
import { GoogleApiKeyCredentials, GoogleApiKeyCredentialsSchema } from '../services/google/base.js';
23-
import { RampCredentials, RampCredentialsSchema } from '../services/ramp.js';
2423
import { SlackApiCredentials, SlackApiCredentialsSchema } from '../services/slack.js';
2524
import { TelegramBotCredentials, TelegramBotCredentialsSchema } from '../services/telegram.js';
2625

@@ -36,7 +35,6 @@ export const ApiCredentialsSchema = z.discriminatedUnion('objectType', [
3635
TelegramBotCredentialsSchema,
3736
AwsCredentialsSchema,
3837
GoogleApiKeyCredentialsSchema,
39-
RampCredentialsSchema,
4038
]);
4139

4240
export type ApiCredentialsData = z.infer<typeof ApiCredentialsSchema>;
@@ -62,8 +60,6 @@ export function deserializeCredentials(data: ApiCredentialsData): ApiCredentials
6260
return AwsCredentials.fromJSON(data);
6361
case 'googleApiKey':
6462
return GoogleApiKeyCredentials.fromJSON(data);
65-
case 'ramp':
66-
return RampCredentials.fromJSON(data);
6763
default: {
6864
const exhaustiveCheck: never = data;
6965
throw new ApiCredentialsSerializationError(
@@ -101,9 +97,6 @@ export function serializeCredentials(credentials: ApiCredentials): ApiCredential
10197
if (credentials instanceof GoogleApiKeyCredentials) {
10298
return credentials.toJSON();
10399
}
104-
if (credentials instanceof RampCredentials) {
105-
return credentials.toJSON();
106-
}
107100
throw new ApiCredentialsSerializationError(`Unknown credential type: ${credentials.objectType}`);
108101
}
109102

src/services/ramp.ts

Lines changed: 27 additions & 259 deletions
Original file line numberDiff line numberDiff line change
@@ -1,114 +1,36 @@
11
/**
2-
* Ramp service implementation. Two authentication pathways, both production:
2+
* Ramp service implementation (browser / AI agent-key pathway, production only).
33
*
4-
* 1. Browser login (`latchkey auth browser ramp`): the OAuth 2.0
5-
* authorization-code + PKCE flow against Ramp's public client (a fixed client
6-
* ID, no secret). The hosted consent screen (auth_level=auto) mints an "AI
7-
* agent key"; latchkey catches the loopback callback, exchanges the code for a
8-
* bearer + refresh token at `.../developer/v1/token/pkce`, and stores them as
9-
* OAuthCredentials (auto-refreshed). Agent keys use the agent-tools endpoints.
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).
109
*
11-
* 2. API client (`latchkey auth set-nocurl ramp <client_id> <client_secret>
12-
* <scope> ...`): the OAuth 2.0 client_credentials grant for single-org access.
13-
* The user registers an API client in the Ramp dashboard, enables scopes, and
14-
* gives latchkey the client ID/secret and those scopes; latchkey mints/refreshes
15-
* a bearer token for exactly those scopes. No refresh token in this grant.
16-
*
17-
* Every API call targets https://api.ramp.com/developer/v1.
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/.
1813
*/
1914

2015
import { randomUUID } from 'node:crypto';
2116
import type { Browser, BrowserContext, Response } from 'playwright';
22-
import { z } from 'zod';
23-
import {
24-
ApiCredentials,
25-
ApiCredentialStatus,
26-
ApiCredentialsUsageError,
27-
OAuthCredentials,
28-
} from '../apiCredentials/base.js';
29-
import { runCaptured } from '../curl.js';
17+
import { ApiCredentials, ApiCredentialStatus, OAuthCredentials } from '../apiCredentials/base.js';
3018
import {
3119
exchangeCodeForTokens,
3220
generateCodeChallenge,
3321
generateCodeVerifier,
3422
refreshAccessToken,
3523
startOAuthCallbackServer,
3624
} from '../oauthUtils.js';
37-
import {
38-
isBrowserClosedError,
39-
LoginCancelledError,
40-
NoCurlCredentialsNotSupportedError,
41-
Service,
42-
ServiceSession,
43-
} from './core/base.js';
44-
45-
/** Client_credentials token endpoint. */
46-
const RAMP_TOKEN_ENDPOINT = 'https://api.ramp.com/developer/v1/token';
47-
48-
/**
49-
* Treat a token as expired this long before its real expiry, so it is never
50-
* used right at the edge of its lifetime.
51-
*/
52-
const EXPIRY_BUFFER_MS = 60_000;
53-
54-
interface RampTokenResponse {
55-
access_token: string;
56-
expires_in: number;
57-
}
58-
59-
/**
60-
* Mint a fresh access token from Ramp using the client_credentials grant.
61-
* Returns null if the request fails or the response is malformed.
62-
*/
63-
function requestRampToken(
64-
clientId: string,
65-
clientSecret: string,
66-
scope: string
67-
): RampTokenResponse | null {
68-
const basicAuth = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
69-
const body = new URLSearchParams({
70-
grant_type: 'client_credentials',
71-
scope,
72-
}).toString();
73-
74-
const result = runCaptured(
75-
[
76-
'-s',
77-
'-X',
78-
'POST',
79-
'-H',
80-
`Authorization: Basic ${basicAuth}`,
81-
'-H',
82-
'Content-Type: application/x-www-form-urlencoded',
83-
'-d',
84-
body,
85-
RAMP_TOKEN_ENDPOINT,
86-
],
87-
30
88-
);
89-
90-
if (result.returncode !== 0) {
91-
return null;
92-
}
93-
94-
try {
95-
const response = JSON.parse(result.stdout) as Partial<RampTokenResponse>;
96-
if (typeof response.access_token !== 'string' || typeof response.expires_in !== 'number') {
97-
return null;
98-
}
99-
return { access_token: response.access_token, expires_in: response.expires_in };
100-
} catch {
101-
return null;
102-
}
103-
}
25+
import { isBrowserClosedError, LoginCancelledError, Service, ServiceSession } from './core/base.js';
10426

10527
/** Ramp's public OAuth client (PKCE, no secret), from ramp-cli. */
10628
const RAMP_OAUTH_CLIENT_ID = 'ramp_id_6pKvd0IR3d8Kuzp82SV6YgpVCZOlz68Px6s3wVsr';
10729

10830
/** Hosted authorize endpoint (where the user signs in / approves the agent key). */
10931
const RAMP_AUTHORIZE_URL = 'https://app.ramp.com/v1/authorize';
11032

111-
/** PKCE token endpoint (code exchange + refresh). Distinct from the `/token` one. */
33+
/** PKCE token endpoint (code exchange + refresh). */
11234
const RAMP_PKCE_TOKEN_ENDPOINT = 'https://api.ramp.com/developer/v1/token/pkce';
11335

11436
/** Loopback callback path; matches ramp-cli's `/callback`. */
@@ -265,141 +187,25 @@ class RampOAuthServiceSession extends ServiceSession {
265187
}
266188
}
267189

268-
/**
269-
* Ramp OAuth client_credentials credentials.
270-
*
271-
* Stores the client ID/secret and the exact scopes the app was granted (used to
272-
* mint tokens) plus the most recently minted access token. The token is injected
273-
* as `Authorization: Bearer`.
274-
*/
275-
export const RampCredentialsSchema = z.object({
276-
objectType: z.literal('ramp'),
277-
clientId: z.string(),
278-
clientSecret: z.string(),
279-
scope: z.string(),
280-
accessToken: z.string().optional(),
281-
accessTokenExpiresAt: z.string().optional(),
282-
});
283-
284-
export type RampCredentialsData = z.infer<typeof RampCredentialsSchema>;
285-
286-
export class RampCredentials implements ApiCredentials {
287-
readonly objectType = 'ramp' as const;
288-
readonly clientId: string;
289-
readonly clientSecret: string;
290-
readonly scope: string;
291-
readonly accessToken?: string;
292-
readonly accessTokenExpiresAt?: string;
293-
294-
constructor(
295-
clientId: string,
296-
clientSecret: string,
297-
scope: string,
298-
accessToken?: string,
299-
accessTokenExpiresAt?: string
300-
) {
301-
this.clientId = clientId;
302-
this.clientSecret = clientSecret;
303-
this.scope = scope;
304-
this.accessToken = accessToken;
305-
this.accessTokenExpiresAt = accessTokenExpiresAt;
306-
}
307-
308-
injectIntoCurlCall(curlArguments: readonly string[]): Promise<readonly string[]> {
309-
if (this.accessToken === undefined) {
310-
throw new ApiCredentialsUsageError(
311-
'Ramp credentials have no access token yet. A token is minted automatically on use; ' +
312-
'if you see this, re-run the command or re-set the credentials.'
313-
);
314-
}
315-
return Promise.resolve(['-H', `Authorization: Bearer ${this.accessToken}`, ...curlArguments]);
316-
}
317-
318-
isExpired(): boolean | undefined {
319-
// No token yet (only client ID/secret stored): report expired so the refresh
320-
// path mints the first token before the request goes out.
321-
if (this.accessToken === undefined) {
322-
return true;
323-
}
324-
if (this.accessTokenExpiresAt === undefined) {
325-
return undefined;
326-
}
327-
return Date.now() >= new Date(this.accessTokenExpiresAt).getTime() - EXPIRY_BUFFER_MS;
328-
}
329-
330-
/** Return a copy carrying a freshly minted access token. */
331-
withToken(accessToken: string, accessTokenExpiresAt: string): RampCredentials {
332-
return new RampCredentials(
333-
this.clientId,
334-
this.clientSecret,
335-
this.scope,
336-
accessToken,
337-
accessTokenExpiresAt
338-
);
339-
}
340-
341-
toJSON(): RampCredentialsData {
342-
return {
343-
objectType: this.objectType,
344-
clientId: this.clientId,
345-
clientSecret: this.clientSecret,
346-
scope: this.scope,
347-
accessToken: this.accessToken,
348-
accessTokenExpiresAt: this.accessTokenExpiresAt,
349-
};
350-
}
351-
352-
static fromJSON(data: RampCredentialsData): RampCredentials {
353-
return new RampCredentials(
354-
data.clientId,
355-
data.clientSecret,
356-
data.scope,
357-
data.accessToken,
358-
data.accessTokenExpiresAt
359-
);
360-
}
361-
}
362-
363-
class RampCredentialError extends NoCurlCredentialsNotSupportedError {
364-
constructor(message: string) {
365-
super('ramp');
366-
this.message = message;
367-
this.name = 'RampCredentialError';
368-
}
369-
}
370-
371190
export class Ramp extends Service {
372191
readonly name = 'ramp';
373192
readonly displayName = 'Ramp';
374193
readonly baseApiUrls = ['https://api.ramp.com/'] as const;
375194
readonly loginUrl = 'https://app.ramp.com/';
376195
readonly info =
377-
'Ramp developer API. Agent-tools OpenAPI spec: https://api.ramp.com/v1/public/agent-tools/spec/. ' +
378-
'Sign in with `latchkey auth browser ramp` to mint an AI agent key; agent keys call ' +
379-
'POST https://api.ramp.com/developer/v1/agent-tools/<tool> with a JSON {"rationale":"..."} body. ' +
380-
'(An API client can also be stored with `latchkey auth set-nocurl ramp <client_id> <client_secret> <scope> ...`.)';
196+
'Ramp developer API for AI agents. Agent-tools OpenAPI spec: https://api.ramp.com/v1/public/agent-tools/spec/. ' +
197+
'Sign in with `latchkey auth browser ramp` to mint an AI agent key; calls are ' +
198+
'POST https://api.ramp.com/developer/v1/agent-tools/<tool> with a JSON {"rationale":"..."} body.';
381199

382-
// Unused: credentials are validated by minting a token (see checkApiCredentials),
383-
// which is scope-independent. Kept for documentation of the simplest read call.
200+
// Unused: browser-login credentials are validated by holding/refreshing a live
201+
// token (see checkApiCredentials), not by hitting a resource endpoint. Only present
202+
// because the base class declares it abstract.
384203
readonly credentialCheckCurlArguments = [
385-
'https://api.ramp.com/developer/v1/transactions',
204+
'https://api.ramp.com/developer/v1/agent-tools/search-help-center-snippets',
386205
] as const;
387206

388207
setCredentialsExample(serviceName: string): string {
389-
return `latchkey auth set-nocurl ${serviceName} <client_id> <client_secret> <scope> [scope ...]`;
390-
}
391-
392-
override getCredentialsNoCurl(arguments_: readonly string[]): ApiCredentials {
393-
const positional = arguments_.filter((argument) => argument !== '');
394-
const [clientId, clientSecret, ...scopes] = positional;
395-
if (clientId === undefined || clientSecret === undefined || scopes.length === 0) {
396-
throw new RampCredentialError(
397-
'Expected: <client_id> <client_secret> <scope> [scope ...]\n' +
398-
'Pass the scopes you enabled on the Ramp app (Settings -> Developer), space-separated.\n' +
399-
'Example: latchkey auth set-nocurl ramp <client_id> <client_secret> transactions:read users:read'
400-
);
401-
}
402-
return new RampCredentials(clientId, clientSecret, scopes.join(' '));
208+
return `latchkey auth browser ${serviceName}`;
403209
}
404210

405211
/**
@@ -411,29 +217,11 @@ export class Ramp extends Service {
411217
}
412218

413219
override refreshCredentials(apiCredentials: ApiCredentials): Promise<ApiCredentials | null> {
414-
// Browser-login credentials: refresh the PKCE access token with the (rotating)
415-
// refresh token against the `/token/pkce` endpoint.
416-
if (apiCredentials instanceof OAuthCredentials) {
417-
return this.refreshOAuthCredentials(apiCredentials);
418-
}
419-
if (!(apiCredentials instanceof RampCredentials)) {
420-
return Promise.resolve(null);
421-
}
422-
const token = requestRampToken(
423-
apiCredentials.clientId,
424-
apiCredentials.clientSecret,
425-
apiCredentials.scope
426-
);
427-
if (token === null) {
220+
// Refresh the PKCE access token with the (rotating) refresh token against the
221+
// `/token/pkce` endpoint, mirroring ramp-cli.
222+
if (!(apiCredentials instanceof OAuthCredentials)) {
428223
return Promise.resolve(null);
429224
}
430-
const accessTokenExpiresAt = new Date(Date.now() + token.expires_in * 1000).toISOString();
431-
return Promise.resolve(apiCredentials.withToken(token.access_token, accessTokenExpiresAt));
432-
}
433-
434-
private refreshOAuthCredentials(
435-
apiCredentials: OAuthCredentials
436-
): Promise<ApiCredentials | null> {
437225
if (apiCredentials.refreshToken === undefined || apiCredentials.refreshToken === '') {
438226
return Promise.resolve(null);
439227
}
@@ -461,34 +249,14 @@ export class Ramp extends Service {
461249
}
462250

463251
/**
464-
* Validate credentials by confirming a token can be minted/refreshed rather than
465-
* by hitting a specific resource endpoint. Ramp has no scope-free endpoint, so a
466-
* resource check would force every user to grant one particular scope; minting is
467-
* the scope-independent source of truth ("can these credentials obtain a token?").
252+
* Validate credentials by confirming a live token is held (refreshing first if
253+
* expired) rather than by hitting a resource endpoint -- Ramp has no scope-free
254+
* endpoint, so a resource check would force the user to grant a particular scope.
468255
*/
469256
override async checkApiCredentials(apiCredentials: ApiCredentials): Promise<ApiCredentialStatus> {
470-
if (apiCredentials instanceof OAuthCredentials) {
471-
return this.checkOAuthCredentials(apiCredentials);
472-
}
473-
if (!(apiCredentials instanceof RampCredentials)) {
257+
if (!(apiCredentials instanceof OAuthCredentials)) {
474258
return ApiCredentialStatus.Missing;
475259
}
476-
let credentials: RampCredentials | null = apiCredentials;
477-
if (credentials.isExpired() === true) {
478-
const refreshed = await this.refreshCredentials(apiCredentials);
479-
credentials = refreshed instanceof RampCredentials ? refreshed : null;
480-
}
481-
if (credentials?.accessToken === undefined) {
482-
return ApiCredentialStatus.Invalid;
483-
}
484-
return credentials.isExpired() === true
485-
? ApiCredentialStatus.Invalid
486-
: ApiCredentialStatus.Valid;
487-
}
488-
489-
private async checkOAuthCredentials(
490-
apiCredentials: OAuthCredentials
491-
): Promise<ApiCredentialStatus> {
492260
let credentials: OAuthCredentials | null = apiCredentials;
493261
if (credentials.isExpired() === true) {
494262
const refreshed = await this.refreshCredentials(apiCredentials);

0 commit comments

Comments
 (0)