diff --git a/.changeset/eighty-bobcats-unite.md b/.changeset/eighty-bobcats-unite.md new file mode 100644 index 00000000000..1947cdd0687 --- /dev/null +++ b/.changeset/eighty-bobcats-unite.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': minor +--- + +Add support for Agent Tasks API endpoint which allows developers to create agent tasks that can be used to act on behalf of users through automated flows. diff --git a/.changeset/gentle-falcons-arrive.md b/.changeset/gentle-falcons-arrive.md new file mode 100644 index 00000000000..f829e6c4ff2 --- /dev/null +++ b/.changeset/gentle-falcons-arrive.md @@ -0,0 +1,5 @@ +--- +'@clerk/testing': minor +--- + +Export `createAgentTestingTask` helper for creating agent tasks via the Clerk Backend API from both `@clerk/testing/playwright` and `@clerk/testing/cypress` subpaths. diff --git a/packages/backend/src/api/__tests__/AgentTaskApi.test.ts b/packages/backend/src/api/__tests__/AgentTaskApi.test.ts new file mode 100644 index 00000000000..8f77842f5f1 --- /dev/null +++ b/packages/backend/src/api/__tests__/AgentTaskApi.test.ts @@ -0,0 +1,96 @@ +import { http, HttpResponse } from 'msw'; +import { describe, expect, it } from 'vitest'; + +import { server, validateHeaders } from '../../mock-server'; +import { createBackendApiClient } from '../factory'; + +describe('AgentTaskAPI', () => { + const apiClient = createBackendApiClient({ + apiUrl: 'https://api.clerk.test', + secretKey: 'deadbeef', + }); + + const mockAgentTaskResponse = { + object: 'agent_task', + agent_id: 'agent_123', + task_id: 'task_456', + url: 'https://example.com/agent-task', + }; + + describe('create', () => { + it('converts nested onBehalfOf.userId to snake_case', async () => { + server.use( + http.post( + 'https://api.clerk.test/v1/agents/tasks', + validateHeaders(async ({ request }) => { + const body = await request.json(); + + expect(body).toEqual({ + on_behalf_of: { + user_id: 'user_123', + }, + permissions: 'read,write', + agent_name: 'test-agent', + task_description: 'Test task', + redirect_url: 'https://example.com/callback', + session_max_duration_in_seconds: 1800, + }); + + return HttpResponse.json(mockAgentTaskResponse); + }), + ), + ); + + const response = await apiClient.agentTasks.create({ + onBehalfOf: { + userId: 'user_123', + }, + permissions: 'read,write', + agentName: 'test-agent', + taskDescription: 'Test task', + redirectUrl: 'https://example.com/callback', + sessionMaxDurationInSeconds: 1800, + }); + + expect(response.agentId).toBe('agent_123'); + expect(response.taskId).toBe('task_456'); + expect(response.url).toBe('https://example.com/agent-task'); + }); + + it('converts nested onBehalfOf.identifier to snake_case', async () => { + server.use( + http.post( + 'https://api.clerk.test/v1/agents/tasks', + validateHeaders(async ({ request }) => { + const body = await request.json(); + + expect(body).toEqual({ + on_behalf_of: { + identifier: 'user@example.com', + }, + permissions: 'read', + agent_name: 'test-agent', + task_description: 'Test task', + redirect_url: 'https://example.com/callback', + }); + + return HttpResponse.json(mockAgentTaskResponse); + }), + ), + ); + + const response = await apiClient.agentTasks.create({ + onBehalfOf: { + identifier: 'user@example.com', + }, + permissions: 'read', + agentName: 'test-agent', + taskDescription: 'Test task', + redirectUrl: 'https://example.com/callback', + }); + + expect(response.agentId).toBe('agent_123'); + expect(response.taskId).toBe('task_456'); + }); + }); +}); diff --git a/packages/backend/src/api/endpoints/AgentTaskApi.ts b/packages/backend/src/api/endpoints/AgentTaskApi.ts new file mode 100644 index 00000000000..ecc55c8bf4d --- /dev/null +++ b/packages/backend/src/api/endpoints/AgentTaskApi.ts @@ -0,0 +1,72 @@ +import { joinPaths } from '../../util/path'; +import type { AgentTask } from '../resources/AgentTask'; +import { AbstractAPI } from './AbstractApi'; + +type CreateAgentTaskParams = { + /** + * The user to create an agent task for. + */ + onBehalfOf: + | { + /** + * The identifier of the user to create an agent task for. + */ + identifier: string; + userId?: never; + } + | { + /** + * The ID of the user to create an agent task for. + */ + userId: string; + identifier?: never; + }; + /** + * The permissions the agent task will have. + */ + permissions: string; + /** + * The name of the agent to create an agent task for. + */ + agentName: string; + /** + * The description of the agent task to create. + */ + taskDescription: string; + /** + * The URL to redirect to after the agent task is consumed. + */ + redirectUrl: string; + + /** + * The maximum duration that the session which will be created by the generated agent task should last. + * By default, the duration is 30 minutes. + */ + sessionMaxDurationInSeconds?: number; +}; + +const basePath = '/agents/tasks'; + +export class AgentTaskAPI extends AbstractAPI { + /** + * @experimental This is an experimental API for the Agent Tokens feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. + */ + public async create(params: CreateAgentTaskParams) { + return this.request({ + method: 'POST', + path: basePath, + bodyParams: params, + options: { + deepSnakecaseBodyParamKeys: true, + }, + }); + } + + public async revoke(agentTaskId: string) { + this.requireId(agentTaskId); + return this.request>({ + method: 'POST', + path: joinPaths(basePath, agentTaskId, 'revoke'), + }); + } +} diff --git a/packages/backend/src/api/endpoints/index.ts b/packages/backend/src/api/endpoints/index.ts index c03875a427d..1f5d7bf7f51 100644 --- a/packages/backend/src/api/endpoints/index.ts +++ b/packages/backend/src/api/endpoints/index.ts @@ -1,4 +1,5 @@ export * from './ActorTokenApi'; +export * from './AgentTaskApi'; export * from './AccountlessApplicationsAPI'; export * from './AbstractApi'; export * from './AllowlistIdentifierApi'; diff --git a/packages/backend/src/api/factory.ts b/packages/backend/src/api/factory.ts index 6c82869a46e..22f1aa89c21 100644 --- a/packages/backend/src/api/factory.ts +++ b/packages/backend/src/api/factory.ts @@ -1,6 +1,7 @@ import { AccountlessApplicationAPI, ActorTokenAPI, + AgentTaskAPI, AllowlistIdentifierAPI, APIKeysAPI, BetaFeaturesAPI, @@ -44,6 +45,10 @@ export function createBackendApiClient(options: CreateBackendApiOptions) { buildRequest({ ...options, requireSecretKey: false }), ), actorTokens: new ActorTokenAPI(request), + /** + * @experimental This is an experimental API for the Agent Tasks feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. + */ + agentTasks: new AgentTaskAPI(request), allowlistIdentifiers: new AllowlistIdentifierAPI(request), apiKeys: new APIKeysAPI( buildRequest({ diff --git a/packages/backend/src/api/resources/AgentTask.ts b/packages/backend/src/api/resources/AgentTask.ts new file mode 100644 index 00000000000..e5e821320f6 --- /dev/null +++ b/packages/backend/src/api/resources/AgentTask.ts @@ -0,0 +1,34 @@ +import type { AgentTaskJSON } from './JSON'; + +/** + * Represents a agent token resource. + * + * Agent tokens are used for testing purposes and allow creating sessions + * for users without requiring full authentication flows. + */ +export class AgentTask { + constructor( + /** + * A stable identifier for the agent, unique per agent_name within an instance. + */ + readonly agentId: string, + /** + * A unique identifier for this agent task. + */ + readonly taskId: string, + /** + * The FAPI URL that, when visited, creates a session for the user. + */ + readonly url: string, + ) {} + + /** + * Creates a AgentTask instance from a JSON object. + * + * @param data - The JSON object containing agent task data + * @returns A new AgentTask instance + */ + static fromJSON(data: AgentTaskJSON): AgentTask { + return new AgentTask(data.agent_id, data.task_id, data.url); + } +} diff --git a/packages/backend/src/api/resources/Deserializer.ts b/packages/backend/src/api/resources/Deserializer.ts index 79fbe119836..626d5e20019 100644 --- a/packages/backend/src/api/resources/Deserializer.ts +++ b/packages/backend/src/api/resources/Deserializer.ts @@ -1,5 +1,6 @@ import { ActorToken, + AgentTask, AllowlistIdentifier, APIKey, BlocklistIdentifier, @@ -169,6 +170,8 @@ function jsonToObject(item: any): any { return SamlConnection.fromJSON(item); case ObjectType.SignInToken: return SignInToken.fromJSON(item); + case ObjectType.AgentTask: + return AgentTask.fromJSON(item); case ObjectType.SignUpAttempt: return SignUpAttempt.fromJSON(item); case ObjectType.Session: diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index 075e0f6fa95..2da34e59185 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -19,6 +19,7 @@ import type { export const ObjectType = { AccountlessApplication: 'accountless_application', ActorToken: 'actor_token', + AgentTask: 'agent_task', AllowlistIdentifier: 'allowlist_identifier', ApiKey: 'api_key', BlocklistIdentifier: 'blocklist_identifier', @@ -512,6 +513,13 @@ export interface SignInTokenJSON extends ClerkResourceJSON { updated_at: number; } +export interface AgentTaskJSON extends ClerkResourceJSON { + object: typeof ObjectType.AgentTask; + agent_id: string; + task_id: string; + url: string; +} + export interface SignUpJSON extends ClerkResourceJSON { object: typeof ObjectType.SignUpAttempt; id: string; diff --git a/packages/backend/src/api/resources/index.ts b/packages/backend/src/api/resources/index.ts index 00e119dbb21..a6d1691f96f 100644 --- a/packages/backend/src/api/resources/index.ts +++ b/packages/backend/src/api/resources/index.ts @@ -1,4 +1,5 @@ export * from './AccountlessApplication'; +export * from './AgentTask'; export * from './ActorToken'; export * from './AllowlistIdentifier'; export * from './APIKey'; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 1559d61f7fb..8905dc45cf1 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -55,6 +55,7 @@ export type { VerifyTokenOptions } from './tokens/verify'; */ export type { ActorTokenJSON, + AgentTaskJSON, AccountlessApplicationJSON, ClerkResourceJSON, TokenJSON, @@ -110,6 +111,7 @@ export type { * Resources */ export type { + AgentTask, APIKey, ActorToken, AccountlessApplication, diff --git a/packages/testing/src/common/agent-task.ts b/packages/testing/src/common/agent-task.ts new file mode 100644 index 00000000000..747ee1ff1b0 --- /dev/null +++ b/packages/testing/src/common/agent-task.ts @@ -0,0 +1,63 @@ +import type { AgentTask, ClerkClient } from '@clerk/backend'; +import { createClerkClient } from '@clerk/backend'; + +export type CreateAgentTaskParams = Parameters[0] & + ( + | { + /** + * The API URL for your Clerk instance. + * If not provided, falls back to the `CLERK_API_URL` environment variable. + */ + apiUrl?: string; + /** + * The secret key for your Clerk instance. + * If not provided, falls back to the `CLERK_SECRET_KEY` environment variable. + */ + secretKey?: string; + + clerkClient?: never; + } + | { + /** + * The Clerk client to use to create the agent task. + * If not provided, a new Clerk client will be created. + */ + clerkClient?: ClerkClient; + apiUrl?: string; + secretKey?: string; + } + ); + +export const ERROR_MISSING_SECRET_KEY = + 'A secretKey is required to create agent tasks. ' + + 'Pass it directly or set the CLERK_SECRET_KEY environment variable.'; + +export const ERROR_MISSING_API_URL = + 'An apiUrl is required to create agent tasks. ' + 'Pass it directly or set the CLERK_API_URL environment variable.'; + +export const ERROR_AGENT_TASK_FAILED = 'Failed to create agent task: '; + +/** + * Creates an agent task using the Clerk Backend API and returns its URL. + * + * @internal Framework-specific wrappers should call this after resolving the secret key. + * + * @experimental This is an experimental API for the Agent Tasks feature that is available under a private beta, + * and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version + * and the clerk-js version to avoid breaking changes. + */ +export async function createAgentTestingTask(params: CreateAgentTaskParams): Promise { + const { apiUrl, secretKey, clerkClient, ...taskParams } = params; + + if (!clerkClient && !secretKey) { + throw new Error(ERROR_MISSING_SECRET_KEY); + } + + const client = clerkClient ?? createClerkClient({ apiUrl, secretKey }); + + try { + return await client.agentTasks.create(taskParams); + } catch (error) { + throw new Error(ERROR_AGENT_TASK_FAILED + (error instanceof Error ? error.message : String(error))); + } +} diff --git a/packages/testing/src/common/index.ts b/packages/testing/src/common/index.ts index b5ddaed5011..bbf12afe13e 100644 --- a/packages/testing/src/common/index.ts +++ b/packages/testing/src/common/index.ts @@ -1,3 +1,4 @@ +export * from './agent-task'; export * from './constants'; export * from './types'; export * from './setup'; diff --git a/packages/testing/src/cypress/agent-task.ts b/packages/testing/src/cypress/agent-task.ts new file mode 100644 index 00000000000..37c8df7bab6 --- /dev/null +++ b/packages/testing/src/cypress/agent-task.ts @@ -0,0 +1,19 @@ +/// +import { type CreateAgentTaskParams, createAgentTestingTask as _createAgentTestingTask } from '../common'; + +/** + * Creates an agent task using the Clerk Backend API and returns its URL. + * + * If `secretKey` is not provided, falls back to the `CLERK_SECRET_KEY` Cypress environment variable. + * + * @experimental This is an experimental API for the Agent Tasks feature that is available under a private beta, + * and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version + * and the clerk-js version to avoid breaking changes. + */ +export function createAgentTestingTask(params: CreateAgentTaskParams) { + return _createAgentTestingTask({ + ...params, + apiUrl: params.apiUrl || Cypress.env('CLERK_API_URL') || process.env.CLERK_API_URL, + secretKey: params.secretKey || Cypress.env('CLERK_SECRET_KEY') || process.env.CLERK_SECRET_KEY, + }); +} diff --git a/packages/testing/src/cypress/index.ts b/packages/testing/src/cypress/index.ts index 95febb24a8e..a06ab01c226 100644 --- a/packages/testing/src/cypress/index.ts +++ b/packages/testing/src/cypress/index.ts @@ -1,3 +1,4 @@ export { clerkSetup } from './setup'; +export { createAgentTestingTask } from './agent-task'; export { setupClerkTestingToken } from './setupClerkTestingToken'; export { addClerkCommands } from './custom-commands'; diff --git a/packages/testing/src/playwright/agent-task.ts b/packages/testing/src/playwright/agent-task.ts new file mode 100644 index 00000000000..164584a73be --- /dev/null +++ b/packages/testing/src/playwright/agent-task.ts @@ -0,0 +1,18 @@ +import { type CreateAgentTaskParams, createAgentTestingTask as _createAgentTestingTask } from '../common'; + +/** + * Creates an agent task using the Clerk Backend API and returns its URL. + * + * If `secretKey` is not provided, falls back to the `CLERK_SECRET_KEY` environment variable. + * + * @experimental This is an experimental API for the Agent Tasks feature that is available under a private beta, + * and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version + * and the clerk-js version to avoid breaking changes. + */ +export function createAgentTestingTask(params: CreateAgentTaskParams) { + return _createAgentTestingTask({ + ...params, + apiUrl: params.apiUrl || process.env.CLERK_API_URL, + secretKey: params.secretKey || process.env.CLERK_SECRET_KEY, + }); +} diff --git a/packages/testing/src/playwright/index.ts b/packages/testing/src/playwright/index.ts index da08ed20634..59a95a4b8d2 100644 --- a/packages/testing/src/playwright/index.ts +++ b/packages/testing/src/playwright/index.ts @@ -1,3 +1,4 @@ export { clerkSetup } from './setup'; +export { createAgentTestingTask } from './agent-task'; export { setupClerkTestingToken } from './setupClerkTestingToken'; export { clerk } from './helpers';