Skip to content
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
38 changes: 38 additions & 0 deletions apps/docs/content/docs/en/tools/cursor.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ List all cloud agents for the authenticated user with optional pagination. Retur
| `apiKey` | string | Yes | Cursor API key |
| `limit` | number | No | Number of agents to return \(default: 20, max: 100\) |
| `cursor` | string | No | Pagination cursor from previous response |
| `prUrl` | string | No | Filter agents by pull request URL |

#### Output

Expand Down Expand Up @@ -173,4 +174,41 @@ Permanently delete a cloud agent. Returns API-aligned fields only.
| --------- | ---- | ----------- |
| `id` | string | Agent ID |

### `cursor_list_artifacts`

List generated artifact files for a cloud agent. Returns API-aligned fields only.

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Cursor API key |
| `agentId` | string | Yes | Unique identifier for the cloud agent \(e.g., bc_abc123\) |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `artifacts` | array | List of artifact files |
| ↳ `path` | string | Artifact file path |
| ↳ `size` | number | File size in bytes |

### `cursor_download_artifact`

Download a generated artifact file from a cloud agent. Returns the file for execution storage.

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Cursor API key |
| `agentId` | string | Yes | Unique identifier for the cloud agent \(e.g., bc_abc123\) |
| `path` | string | Yes | Absolute path of the artifact to download \(e.g., /src/index.ts\) |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `file` | file | Downloaded artifact file stored in execution files |


10 changes: 9 additions & 1 deletion apps/sim/app/(landing)/integrations/data/integrations.json
Original file line number Diff line number Diff line change
Expand Up @@ -2327,9 +2327,17 @@
{
"name": "Delete Agent",
"description": "Permanently delete a cloud agent. This action cannot be undone."
},
{
"name": "List Artifacts",
"description": "List generated artifact files for a cloud agent."
},
{
"name": "Download Artifact",
"description": "Download a generated artifact file from a cloud agent."
}
],
"operationCount": 7,
"operationCount": 9,
"triggers": [],
"triggerCount": 0,
"authType": "api-key",
Expand Down
146 changes: 146 additions & 0 deletions apps/sim/app/api/tools/cursor/download-artifact/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request'

export const dynamic = 'force-dynamic'

const logger = createLogger('CursorDownloadArtifactAPI')

const DownloadArtifactSchema = z.object({
apiKey: z.string().min(1, 'API key is required'),
agentId: z.string().min(1, 'Agent ID is required'),
path: z.string().min(1, 'Artifact path is required'),
})

export async function POST(request: NextRequest) {
const requestId = generateRequestId()

try {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })

if (!authResult.success) {
logger.warn(
`[${requestId}] Unauthorized Cursor download artifact attempt: ${authResult.error}`
)
return NextResponse.json(
{ success: false, error: authResult.error || 'Authentication required' },
{ status: 401 }
)
}

logger.info(
`[${requestId}] Authenticated Cursor download artifact request via ${authResult.authType}`,
{
userId: authResult.userId,
}
)

const body = await request.json()
const { apiKey, agentId, path } = DownloadArtifactSchema.parse(body)

const authHeader = `Basic ${Buffer.from(`${apiKey}:`).toString('base64')}`

logger.info(`[${requestId}] Requesting presigned URL for artifact`, { agentId, path })

const artifactResponse = await fetch(
`https://api.cursor.com/v0/agents/${encodeURIComponent(agentId)}/artifacts/download?path=${encodeURIComponent(path)}`,
{
method: 'GET',
headers: {
Authorization: authHeader,
},
}
)

if (!artifactResponse.ok) {
const errorText = await artifactResponse.text().catch(() => '')
logger.error(`[${requestId}] Failed to get artifact presigned URL`, {
status: artifactResponse.status,
error: errorText,
})
return NextResponse.json(
{
success: false,
error: errorText || `Failed to get artifact URL (${artifactResponse.status})`,
},
{ status: artifactResponse.status }
)
}

const artifactData = await artifactResponse.json()
const downloadUrl = artifactData.url || artifactData.downloadUrl || artifactData.presignedUrl

if (!downloadUrl) {
logger.error(`[${requestId}] No download URL in artifact response`, { artifactData })
return NextResponse.json(
{ success: false, error: 'No download URL returned for artifact' },
{ status: 400 }
)
}

const urlValidation = await validateUrlWithDNS(downloadUrl, 'downloadUrl')
if (!urlValidation.isValid) {
return NextResponse.json({ success: false, error: urlValidation.error }, { status: 400 })
}

logger.info(`[${requestId}] Downloading artifact from presigned URL`, { agentId, path })

const downloadResponse = await secureFetchWithPinnedIP(
downloadUrl,
urlValidation.resolvedIP!,
{}
)

if (!downloadResponse.ok) {
logger.error(`[${requestId}] Failed to download artifact content`, {
status: downloadResponse.status,
statusText: downloadResponse.statusText,
})
return NextResponse.json(
{
success: false,
error: `Failed to download artifact content (${downloadResponse.status}: ${downloadResponse.statusText})`,
},
{ status: downloadResponse.status }
)
}

const contentType = downloadResponse.headers.get('content-type') || 'application/octet-stream'
const arrayBuffer = await downloadResponse.arrayBuffer()
const fileBuffer = Buffer.from(arrayBuffer)

const fileName = path.split('/').pop() || 'artifact'

logger.info(`[${requestId}] Artifact downloaded successfully`, {
agentId,
path,
name: fileName,
size: fileBuffer.length,
mimeType: contentType,
})

return NextResponse.json({
success: true,
output: {
file: {
name: fileName,
mimeType: contentType,
data: fileBuffer.toString('base64'),
size: fileBuffer.length,
},
},
})
} catch (error) {
logger.error(`[${requestId}] Error downloading Cursor artifact:`, error)
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error occurred' },
{ status: 500 }
)
}
}
48 changes: 47 additions & 1 deletion apps/sim/blocks/blocks/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export const CursorBlock: BlockConfig<CursorResponse> = {
{ label: 'List Agents', id: 'cursor_list_agents' },
{ label: 'Stop Agent', id: 'cursor_stop_agent' },
{ label: 'Delete Agent', id: 'cursor_delete_agent' },
{ label: 'List Artifacts', id: 'cursor_list_artifacts' },
{ label: 'Download Artifact', id: 'cursor_download_artifact' },
],
value: () => 'cursor_launch_agent',
},
Expand All @@ -48,6 +50,7 @@ export const CursorBlock: BlockConfig<CursorResponse> = {
type: 'short-input',
placeholder: 'main (optional)',
condition: { field: 'operation', value: 'cursor_launch_agent' },
mode: 'advanced',
},
{
id: 'promptText',
Expand All @@ -57,19 +60,29 @@ export const CursorBlock: BlockConfig<CursorResponse> = {
condition: { field: 'operation', value: 'cursor_launch_agent' },
required: true,
},
{
id: 'promptImages',
title: 'Prompt Images',
type: 'long-input',
placeholder: '[{"data": "base64...", "dimension": {"width": 1024, "height": 768}}]',
condition: { field: 'operation', value: ['cursor_launch_agent', 'cursor_add_followup'] },
mode: 'advanced',
},
{
id: 'model',
title: 'Model',
type: 'short-input',
placeholder: 'Auto-selection by default',
condition: { field: 'operation', value: 'cursor_launch_agent' },
mode: 'advanced',
},
{
id: 'branchName',
title: 'Branch Name',
type: 'short-input',
placeholder: 'Custom branch name (optional)',
condition: { field: 'operation', value: 'cursor_launch_agent' },
mode: 'advanced',
},
{
id: 'autoCreatePr',
Expand All @@ -82,12 +95,14 @@ export const CursorBlock: BlockConfig<CursorResponse> = {
title: 'Open as Cursor GitHub App',
type: 'switch',
condition: { field: 'operation', value: 'cursor_launch_agent' },
mode: 'advanced',
},
{
id: 'skipReviewerRequest',
title: 'Skip Reviewer Request',
type: 'switch',
condition: { field: 'operation', value: 'cursor_launch_agent' },
mode: 'advanced',
},
{
id: 'agentId',
Expand All @@ -102,10 +117,20 @@ export const CursorBlock: BlockConfig<CursorResponse> = {
'cursor_add_followup',
'cursor_stop_agent',
'cursor_delete_agent',
'cursor_list_artifacts',
'cursor_download_artifact',
],
},
required: true,
},
{
id: 'path',
title: 'Artifact Path',
type: 'short-input',
placeholder: '/opt/cursor/artifacts/screenshot.png',
condition: { field: 'operation', value: 'cursor_download_artifact' },
required: true,
},
{
id: 'followupPromptText',
title: 'Follow-up Prompt',
Expand All @@ -114,19 +139,29 @@ export const CursorBlock: BlockConfig<CursorResponse> = {
condition: { field: 'operation', value: 'cursor_add_followup' },
required: true,
},
{
id: 'prUrl',
title: 'PR URL Filter',
type: 'short-input',
placeholder: 'Filter by pull request URL (optional)',
condition: { field: 'operation', value: 'cursor_list_agents' },
mode: 'advanced',
},
{
id: 'limit',
title: 'Limit',
type: 'short-input',
placeholder: '20 (default, max 100)',
condition: { field: 'operation', value: 'cursor_list_agents' },
mode: 'advanced',
},
{
id: 'cursor',
title: 'Pagination Cursor',
type: 'short-input',
placeholder: 'Cursor from previous response',
condition: { field: 'operation', value: 'cursor_list_agents' },
mode: 'advanced',
},
{
id: 'apiKey',
Expand All @@ -146,6 +181,8 @@ export const CursorBlock: BlockConfig<CursorResponse> = {
'cursor_add_followup',
'cursor_stop_agent',
'cursor_delete_agent',
'cursor_list_artifacts',
'cursor_download_artifact',
],
config: {
tool: (params) => params.operation || 'cursor_launch_agent',
Expand All @@ -157,15 +194,20 @@ export const CursorBlock: BlockConfig<CursorResponse> = {
ref: { type: 'string', description: 'Branch, tag, or commit reference' },
promptText: { type: 'string', description: 'Instruction text for the agent' },
followupPromptText: { type: 'string', description: 'Follow-up instruction text for the agent' },
promptImages: { type: 'string', description: 'JSON array of image objects' },
promptImages: {
type: 'string',
description: 'JSON array of image objects with base64 data and dimensions',
},
model: { type: 'string', description: 'Model to use (empty for auto-selection)' },
branchName: { type: 'string', description: 'Custom branch name' },
autoCreatePr: { type: 'boolean', description: 'Auto-create PR when done' },
openAsCursorGithubApp: { type: 'boolean', description: 'Open PR as Cursor GitHub App' },
skipReviewerRequest: { type: 'boolean', description: 'Skip reviewer request' },
agentId: { type: 'string', description: 'Agent identifier' },
prUrl: { type: 'string', description: 'Filter agents by pull request URL' },
limit: { type: 'number', description: 'Number of results to return' },
cursor: { type: 'string', description: 'Pagination cursor' },
path: { type: 'string', description: 'Absolute path of the artifact to download' },
apiKey: { type: 'string', description: 'Cursor API key' },
},
outputs: {
Expand All @@ -192,6 +234,8 @@ export const CursorV2Block: BlockConfig<CursorResponse> = {
'cursor_add_followup_v2',
'cursor_stop_agent_v2',
'cursor_delete_agent_v2',
'cursor_list_artifacts_v2',
'cursor_download_artifact_v2',
],
config: {
tool: createVersionedToolSelector({
Expand All @@ -213,5 +257,7 @@ export const CursorV2Block: BlockConfig<CursorResponse> = {
agents: { type: 'json', description: 'Array of agent objects (list operation)' },
nextCursor: { type: 'string', description: 'Pagination cursor (list operation)' },
messages: { type: 'json', description: 'Conversation messages (get conversation operation)' },
artifacts: { type: 'json', description: 'List of artifact files (list artifacts operation)' },
file: { type: 'file', description: 'Downloaded artifact file (download artifact operation)' },
},
}
2 changes: 1 addition & 1 deletion apps/sim/tools/cursor/add_followup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const addFollowupBase = {
},
request: {
url: (params: AddFollowupParams) =>
`https://api.cursor.com/v0/agents/${params.agentId}/followup`,
`https://api.cursor.com/v0/agents/${params.agentId.trim()}/followup`,
method: 'POST',
headers: (params: AddFollowupParams) => ({
'Content-Type': 'application/json',
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/tools/cursor/delete_agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const deleteAgentBase = {
},
},
request: {
url: (params: DeleteAgentParams) => `https://api.cursor.com/v0/agents/${params.agentId}`,
url: (params: DeleteAgentParams) => `https://api.cursor.com/v0/agents/${params.agentId.trim()}`,
method: 'DELETE',
headers: (params: DeleteAgentParams) => ({
Authorization: `Basic ${Buffer.from(`${params.apiKey}:`).toString('base64')}`,
Expand Down
Loading
Loading