Skip to content

Commit cf5f56f

Browse files
committed
fix(browser-use): validate operation payloads
1 parent 52f86da commit cf5f56f

3 files changed

Lines changed: 248 additions & 33 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { executeRunTaskOperation } from '@/lib/internal/browser-use/operations/run-task'
6+
7+
const mockFetch = vi.fn<typeof fetch>()
8+
9+
function jsonResponse(body: unknown, status = 200): Response {
10+
return new Response(JSON.stringify(body), {
11+
status,
12+
headers: { 'Content-Type': 'application/json' },
13+
})
14+
}
15+
16+
describe('executeRunTaskOperation', () => {
17+
beforeEach(() => {
18+
vi.clearAllMocks()
19+
vi.stubGlobal('fetch', mockFetch)
20+
})
21+
22+
afterEach(() => vi.unstubAllGlobals())
23+
24+
it('validates provider payloads while preserving the documented task output', async () => {
25+
mockFetch
26+
.mockResolvedValueOnce(jsonResponse({ id: 'task-1', sessionId: 'session-1' }))
27+
.mockResolvedValueOnce(
28+
jsonResponse({
29+
status: 'finished',
30+
sessionId: 'session-1',
31+
output: { result: 'complete' },
32+
steps: [
33+
{
34+
number: 1,
35+
memory: 'Opened the page',
36+
evaluationPreviousGoal: 'Succeeded',
37+
nextGoal: 'Finish',
38+
url: 'https://example.com',
39+
actions: ['{"click":{"index":1}}'],
40+
providerField: 'preserved',
41+
},
42+
],
43+
})
44+
)
45+
.mockResolvedValueOnce(
46+
jsonResponse({
47+
liveUrl: 'https://live.browser-use.com/session-1',
48+
publicShareUrl: 'https://browser-use.com/share/session-1',
49+
})
50+
)
51+
52+
const result = await executeRunTaskOperation({ task: 'Open the page', apiKey: 'api-key' })
53+
54+
expect(result).toEqual({
55+
success: true,
56+
output: {
57+
id: 'task-1',
58+
success: true,
59+
output: { result: 'complete' },
60+
steps: [
61+
{
62+
number: 1,
63+
memory: 'Opened the page',
64+
evaluationPreviousGoal: 'Succeeded',
65+
nextGoal: 'Finish',
66+
url: 'https://example.com',
67+
actions: ['{"click":{"index":1}}'],
68+
providerField: 'preserved',
69+
},
70+
],
71+
liveUrl: 'https://live.browser-use.com/session-1',
72+
shareUrl: 'https://browser-use.com/share/session-1',
73+
sessionId: 'session-1',
74+
},
75+
error: undefined,
76+
})
77+
expect(mockFetch).toHaveBeenCalledTimes(3)
78+
})
79+
80+
it('rejects a malformed successful create-task response', async () => {
81+
mockFetch.mockResolvedValueOnce(jsonResponse({ sessionId: 'session-1' }))
82+
83+
await expect(
84+
executeRunTaskOperation({ task: 'Open the page', apiKey: 'api-key' })
85+
).resolves.toEqual({
86+
success: false,
87+
output: {
88+
id: '',
89+
success: false,
90+
output: null,
91+
steps: [],
92+
liveUrl: null,
93+
shareUrl: null,
94+
sessionId: null,
95+
},
96+
error: 'BrowserUse returned an invalid create-task response',
97+
})
98+
})
99+
100+
it('normalizes non-Error provider failures', async () => {
101+
mockFetch.mockRejectedValueOnce('provider unavailable')
102+
103+
await expect(
104+
executeRunTaskOperation({ task: 'Open the page', apiKey: 'api-key' })
105+
).resolves.toEqual({
106+
success: false,
107+
output: {
108+
id: '',
109+
success: false,
110+
output: null,
111+
steps: [],
112+
liveUrl: null,
113+
shareUrl: null,
114+
sessionId: null,
115+
},
116+
error: 'Error creating task: provider unavailable',
117+
})
118+
})
119+
})

apps/sim/lib/internal/browser-use/operations/run-task.ts

Lines changed: 126 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
23
import { sleep } from '@sim/utils/helpers'
4+
import { z } from 'zod'
35
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
46
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
5-
import type { BrowserUseRunTaskParams, BrowserUseRunTaskResponse } from '@/tools/browser_use/types'
7+
import type {
8+
BrowserUseRunTaskParams,
9+
BrowserUseRunTaskResponse,
10+
BrowserUseTaskStep,
11+
} from '@/tools/browser_use/types'
612

713
const logger = createLogger('BrowserUseTool')
814

@@ -11,6 +17,61 @@ const MAX_POLL_TIME_MS = getMaxExecutionTimeout()
1117
const MAX_CONSECUTIVE_ERRORS = 3
1218
const API_BASE = 'https://api.browser-use.com/api/v2'
1319

20+
const createSessionResponseSchema = z.object({
21+
id: z.string().min(1),
22+
})
23+
24+
const sessionDetailsResponseSchema = z.object({
25+
liveUrl: z.string().nullable().optional(),
26+
publicShareUrl: z.string().nullable().optional(),
27+
})
28+
29+
const taskStepSchema: z.ZodType<BrowserUseTaskStep> = z
30+
.object({
31+
number: z.number(),
32+
memory: z.string(),
33+
evaluationPreviousGoal: z.string(),
34+
nextGoal: z.string(),
35+
url: z.string(),
36+
screenshotUrl: z.string().nullable().optional(),
37+
actions: z.array(z.string()),
38+
duration: z.number().nullable().optional(),
39+
})
40+
.passthrough()
41+
42+
const taskStatusResponseSchema = z.object({
43+
status: z.string(),
44+
sessionId: z.string().nullable().optional(),
45+
output: z.unknown().optional(),
46+
steps: z.array(taskStepSchema).optional(),
47+
})
48+
49+
const createTaskResponseSchema = z.object({
50+
id: z.string().min(1),
51+
sessionId: z.string().nullable().optional(),
52+
})
53+
54+
const shareResponseSchema = z.object({
55+
shareUrl: z.string().nullable().optional(),
56+
})
57+
58+
interface BrowserUseTaskRequest {
59+
task: string
60+
sessionId?: string
61+
llm?: string
62+
startUrl?: string
63+
maxSteps?: number
64+
structuredOutput?: string
65+
flashMode?: boolean
66+
thinking?: boolean
67+
vision?: boolean | 'auto'
68+
systemPromptExtension?: string
69+
highlightElements?: boolean
70+
allowedDomains?: string[]
71+
secrets?: Record<string, unknown>
72+
metadata?: Record<string, string>
73+
}
74+
1475
async function createSessionWithProfile(
1576
profileId: string,
1677
apiKey: string
@@ -33,12 +94,17 @@ async function createSessionWithProfile(
3394
return { error: `Failed to create session with profile: ${response.statusText}` }
3495
}
3596

36-
const data = (await response.json()) as { id: string }
97+
const parsed = createSessionResponseSchema.safeParse(await response.json())
98+
if (!parsed.success) {
99+
logger.error('BrowserUse returned an invalid create-session response')
100+
return { error: 'BrowserUse returned an invalid create-session response' }
101+
}
102+
const data = parsed.data
37103
logger.info(`Created session ${data.id} with profile ${profileId}`)
38104
return { sessionId: data.id }
39-
} catch (error: any) {
105+
} catch (error: unknown) {
40106
logger.error('Error creating session with profile:', error)
41-
return { error: `Error creating session: ${error.message}` }
107+
return { error: `Error creating session: ${getErrorMessage(error, 'Unknown error')}` }
42108
}
43109
}
44110

@@ -58,7 +124,7 @@ async function stopSession(sessionId: string, apiKey: string): Promise<void> {
58124
} else {
59125
logger.warn(`Failed to stop session ${sessionId}: ${response.statusText}`)
60126
}
61-
} catch (error: any) {
127+
} catch (error: unknown) {
62128
logger.warn(`Error stopping session ${sessionId}:`, error)
63129
}
64130
}
@@ -75,27 +141,38 @@ async function fetchSessionLiveUrl(
75141
if (!response.ok) {
76142
return { liveUrl: null, publicShareUrl: null }
77143
}
78-
const data = (await response.json()) as { liveUrl?: string; publicShareUrl?: string }
144+
const parsed = sessionDetailsResponseSchema.safeParse(await response.json())
145+
if (!parsed.success) {
146+
logger.warn(`BrowserUse returned an invalid session response for ${sessionId}`)
147+
return { liveUrl: null, publicShareUrl: null }
148+
}
149+
const data = parsed.data
79150
return {
80151
liveUrl: data.liveUrl ?? null,
81152
publicShareUrl: data.publicShareUrl ?? null,
82153
}
83-
} catch (error: any) {
154+
} catch (error: unknown) {
84155
logger.warn(`Error fetching session ${sessionId}:`, error)
85156
return { liveUrl: null, publicShareUrl: null }
86157
}
87158
}
88159

89-
function normalizeSecrets(variables: BrowserUseRunTaskParams['variables']): Record<string, string> {
90-
const secrets: Record<string, string> = {}
160+
function normalizeSecrets(
161+
variables: BrowserUseRunTaskParams['variables']
162+
): Record<string, unknown> {
163+
const secrets: Record<string, unknown> = {}
91164
if (!variables) return secrets
92165

93166
if (Array.isArray(variables)) {
94-
for (const row of variables as Array<Record<string, any>>) {
95-
if (row?.cells?.Key && row.cells.Value !== undefined) {
96-
secrets[row.cells.Key] = row.cells.Value
97-
} else if (row?.Key && row.Value !== undefined) {
98-
secrets[row.Key] = row.Value
167+
for (const row of variables) {
168+
const cells =
169+
typeof row.cells === 'object' && row.cells !== null
170+
? (row.cells as Record<string, unknown>)
171+
: undefined
172+
const key = cells?.Key ?? row.Key
173+
const value = cells?.Value ?? row.Value
174+
if (key && value !== undefined) {
175+
secrets[String(key)] = value
99176
}
100177
}
101178
} else if (typeof variables === 'object') {
@@ -120,8 +197,8 @@ function parseAllowedDomains(input?: string | string[]): string[] | undefined {
120197
function buildRequestBody(
121198
params: BrowserUseRunTaskParams,
122199
sessionId?: string
123-
): Record<string, any> {
124-
const body: Record<string, any> = { task: params.task }
200+
): BrowserUseTaskRequest {
201+
const body: BrowserUseTaskRequest = { task: params.task }
125202

126203
if (sessionId) body.sessionId = sessionId
127204
if (params.model) body.llm = params.model
@@ -154,7 +231,9 @@ function buildRequestBody(
154231
async function fetchTaskStatus(
155232
taskId: string,
156233
apiKey: string
157-
): Promise<{ ok: true; data: any } | { ok: false; error: string }> {
234+
): Promise<
235+
{ ok: true; data: z.infer<typeof taskStatusResponseSchema> } | { ok: false; error: string }
236+
> {
158237
try {
159238
const response = await fetch(`${API_BASE}/tasks/${taskId}`, {
160239
method: 'GET',
@@ -165,16 +244,20 @@ async function fetchTaskStatus(
165244
return { ok: false, error: `HTTP ${response.status}: ${response.statusText}` }
166245
}
167246

168-
return { ok: true, data: await response.json() }
169-
} catch (error: any) {
170-
return { ok: false, error: error.message || 'Network error' }
247+
const parsed = taskStatusResponseSchema.safeParse(await response.json())
248+
if (!parsed.success) {
249+
return { ok: false, error: 'BrowserUse returned an invalid task-status response' }
250+
}
251+
return { ok: true, data: parsed.data }
252+
} catch (error: unknown) {
253+
return { ok: false, error: getErrorMessage(error, 'Network error') }
171254
}
172255
}
173256

174257
interface PollResult {
175258
success: boolean
176-
output: any
177-
steps: any[]
259+
output: unknown
260+
steps: BrowserUseTaskStep[]
178261
sessionId: string | null
179262
liveUrl: string | null
180263
publicShareUrl: string | null
@@ -233,7 +316,7 @@ async function pollForCompletion(taskId: string, apiKey: string): Promise<PollRe
233316
return {
234317
success: status === 'finished',
235318
output: taskData.output ?? null,
236-
steps: taskData.steps || [],
319+
steps: taskData.steps ?? [],
237320
sessionId,
238321
liveUrl,
239322
publicShareUrl,
@@ -248,7 +331,7 @@ async function pollForCompletion(taskId: string, apiKey: string): Promise<PollRe
248331
return {
249332
success: finalResult.data.status === 'finished',
250333
output: finalResult.data.output ?? null,
251-
steps: finalResult.data.steps || [],
334+
steps: finalResult.data.steps ?? [],
252335
sessionId: finalResult.data.sessionId ?? sessionId,
253336
liveUrl,
254337
publicShareUrl,
@@ -281,9 +364,13 @@ async function createShareUrl(sessionId: string, apiKey: string): Promise<string
281364
return null
282365
}
283366

284-
const data = (await response.json()) as { shareUrl?: string; shareToken?: string }
285-
return data.shareUrl ?? null
286-
} catch (error: any) {
367+
const parsed = shareResponseSchema.safeParse(await response.json())
368+
if (!parsed.success) {
369+
logger.warn(`BrowserUse returned an invalid share response for session ${sessionId}`)
370+
return null
371+
}
372+
return parsed.data.shareUrl ?? null
373+
} catch (error: unknown) {
287374
logger.warn(`Error creating share URL for session ${sessionId}:`, error)
288375
return null
289376
}
@@ -338,7 +425,16 @@ export const executeRunTaskOperation: InternalToolOperationImplementation<
338425
}
339426
}
340427

341-
const data = (await response.json()) as { id: string; sessionId?: string }
428+
const parsed = createTaskResponseSchema.safeParse(await response.json())
429+
if (!parsed.success) {
430+
logger.error('BrowserUse returned an invalid create-task response')
431+
return {
432+
success: false,
433+
output: emptyOutput(),
434+
error: 'BrowserUse returned an invalid create-task response',
435+
}
436+
}
437+
const data = parsed.data
342438
const taskId = data.id
343439
const initialSessionId = sessionId ?? data.sessionId ?? null
344440
logger.info(`Created BrowserUse task ${taskId}`, { sessionId: initialSessionId })
@@ -367,15 +463,15 @@ export const executeRunTaskOperation: InternalToolOperationImplementation<
367463
},
368464
error: result.error,
369465
}
370-
} catch (error: any) {
466+
} catch (error: unknown) {
371467
logger.error('Error creating BrowserUse task:', error)
372468
if (sessionId) {
373469
await stopSession(sessionId, params.apiKey)
374470
}
375471
return {
376472
success: false,
377473
output: emptyOutput(),
378-
error: `Error creating task: ${error.message}`,
474+
error: `Error creating task: ${getErrorMessage(error, 'Unknown error')}`,
379475
}
380476
}
381477
}

0 commit comments

Comments
 (0)