Skip to content

Commit afe5e33

Browse files
committed
fix(tools): harden provider operation contracts
1 parent 5feb2d1 commit afe5e33

12 files changed

Lines changed: 296 additions & 33 deletions

File tree

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

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,56 @@ describe('executeRunTaskOperation', () => {
8585
}
8686
})
8787

88+
it('uses the created profile session to fetch the live URL when task status omits it', async () => {
89+
mockFetch
90+
.mockResolvedValueOnce(jsonResponse({ id: 'profile-session' }))
91+
.mockResolvedValueOnce(jsonResponse({ id: 'task-1' }))
92+
.mockResolvedValueOnce(jsonResponse({ status: 'finished', output: 'done' }))
93+
.mockResolvedValueOnce(
94+
jsonResponse({
95+
liveUrl: 'https://live.browser-use.com/profile-session',
96+
publicShareUrl: 'https://browser-use.com/share/profile-session',
97+
})
98+
)
99+
.mockResolvedValueOnce(new Response(null, { status: 204 }))
100+
101+
const result = await executeRunTaskOperation({
102+
task: 'Open the page',
103+
apiKey: 'api-key',
104+
profile_id: 'profile-1',
105+
})
106+
107+
expect(result.output).toMatchObject({
108+
sessionId: 'profile-session',
109+
liveUrl: 'https://live.browser-use.com/profile-session',
110+
shareUrl: 'https://browser-use.com/share/profile-session',
111+
})
112+
expect(mockFetch).toHaveBeenNthCalledWith(
113+
4,
114+
'https://api.browser-use.com/api/v2/sessions/profile-session',
115+
expect.objectContaining({ method: 'GET' })
116+
)
117+
})
118+
119+
it('returns an actionable error for a terminal failed task', async () => {
120+
mockFetch
121+
.mockResolvedValueOnce(jsonResponse({ id: 'task-1' }))
122+
.mockResolvedValueOnce(
123+
jsonResponse({ status: 'failed', output: 'Navigation could not reach the target' })
124+
)
125+
126+
const result = await executeRunTaskOperation({ task: 'Open the page', apiKey: 'api-key' })
127+
128+
expect(result).toMatchObject({
129+
success: false,
130+
error: 'BrowserUse task failed: Navigation could not reach the target',
131+
output: {
132+
success: false,
133+
output: 'Navigation could not reach the target',
134+
},
135+
})
136+
})
137+
88138
it('rejects a malformed successful create-task response', async () => {
89139
mockFetch.mockResolvedValueOnce(jsonResponse({ sessionId: 'session-1' }))
90140

@@ -148,6 +198,7 @@ describe('executeRunTaskOperation', () => {
148198
method: 'PATCH',
149199
body: JSON.stringify({ action: 'stop' }),
150200
redirect: 'error',
201+
signal: expect.any(AbortSignal),
151202
})
152203
)
153204
})
@@ -174,7 +225,12 @@ describe('executeRunTaskOperation', () => {
174225
expect(mockFetch).toHaveBeenNthCalledWith(
175226
3,
176227
'https://api.browser-use.com/api/v2/sessions/profile-session',
177-
expect.objectContaining({ method: 'PATCH', redirect: 'error' })
228+
expect.objectContaining({
229+
method: 'PATCH',
230+
redirect: 'error',
231+
signal: expect.any(AbortSignal),
232+
})
178233
)
234+
expect(mockFetch.mock.calls[2]?.[1]?.signal).not.toBe(controller.signal)
179235
})
180236
})

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

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const taskStatusResponseSchema = z.object({
4545
output: z.unknown().optional(),
4646
steps: z.array(taskStepSchema).optional(),
4747
})
48+
const SESSION_CLEANUP_TIMEOUT_MS = 10_000
4849

4950
const createTaskResponseSchema = z.object({
5051
id: z.string().min(1),
@@ -159,6 +160,7 @@ async function stopSession(sessionId: string, apiKey: string): Promise<void> {
159160
const response = await fetchBrowserUse(`/sessions/${encodeURIComponent(sessionId)}`, apiKey, {
160161
method: 'PATCH',
161162
body: { action: 'stop' },
163+
signal: AbortSignal.timeout(SESSION_CLEANUP_TIMEOUT_MS),
162164
})
163165

164166
if (response.ok) {
@@ -313,10 +315,11 @@ interface PollResult {
313315
async function pollForCompletion(
314316
taskId: string,
315317
apiKey: string,
318+
initialSessionId: string | null,
316319
signal?: AbortSignal
317320
): Promise<PollResult> {
318321
let consecutiveErrors = 0
319-
let sessionId: string | null = null
322+
let sessionId = initialSessionId
320323
let liveUrl: string | null = null
321324
let publicShareUrl: string | null = null
322325
const startTime = Date.now()
@@ -364,13 +367,20 @@ async function pollForCompletion(
364367
}
365368

366369
if (['finished', 'failed', 'stopped'].includes(status)) {
370+
const output = taskData.output ?? null
367371
return {
368372
success: status === 'finished',
369-
output: taskData.output ?? null,
373+
output,
370374
steps: taskData.steps ?? [],
371375
sessionId,
372376
liveUrl,
373377
publicShareUrl,
378+
error:
379+
status === 'finished'
380+
? undefined
381+
: typeof output === 'string' && output.trim()
382+
? `BrowserUse task ${status}: ${output.trim()}`
383+
: `BrowserUse task ${status}`,
374384
}
375385
}
376386

@@ -379,13 +389,21 @@ async function pollForCompletion(
379389

380390
const finalResult = await fetchTaskStatus(taskId, apiKey, signal)
381391
if (finalResult.ok && ['finished', 'failed', 'stopped'].includes(finalResult.data.status)) {
392+
const status = finalResult.data.status
393+
const output = finalResult.data.output ?? null
382394
return {
383-
success: finalResult.data.status === 'finished',
384-
output: finalResult.data.output ?? null,
395+
success: status === 'finished',
396+
output,
385397
steps: finalResult.data.steps ?? [],
386398
sessionId: finalResult.data.sessionId ?? sessionId,
387399
liveUrl,
388400
publicShareUrl,
401+
error:
402+
status === 'finished'
403+
? undefined
404+
: typeof output === 'string' && output.trim()
405+
? `BrowserUse task ${status}: ${output.trim()}`
406+
: `BrowserUse task ${status}`,
389407
}
390408
}
391409

@@ -497,7 +515,7 @@ export const executeRunTaskOperation: InternalToolOperationImplementation<
497515
const initialSessionId = sessionId ?? data.sessionId ?? null
498516
logger.info(`Created BrowserUse task ${taskId}`, { sessionId: initialSessionId })
499517

500-
const result = await pollForCompletion(taskId, params.apiKey, signal)
518+
const result = await pollForCompletion(taskId, params.apiKey, initialSessionId, signal)
501519

502520
const finalSessionId = result.sessionId ?? initialSessionId
503521
const shareUrl =

apps/sim/lib/internal/github/operations.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,16 @@ interface ReviewCommentBody {
2929
event: 'COMMENT'
3030
}
3131

32-
interface FileCommentBody {
32+
interface FileCommentBodyBase {
3333
body: string
3434
commit_id: string | undefined
3535
path: string | undefined
36-
line: number | undefined
37-
side: string
3836
}
3937

38+
type FileCommentBody =
39+
| (FileCommentBodyBase & { subject_type: 'file' })
40+
| (FileCommentBodyBase & { line: number; side: string })
41+
4042
interface GitHubCommentPayload {
4143
id?: number
4244
body?: string
@@ -112,10 +114,16 @@ function toLineNumber(value: unknown): number | undefined {
112114
if (typeof value === 'number') {
113115
parsed = value
114116
} else {
115-
if (typeof value !== 'string' || !value.trim()) return undefined
117+
if (value === undefined || value === null) return undefined
118+
if (typeof value !== 'string') {
119+
throw new Error('GitHub line must be a positive integer')
120+
}
121+
if (!value.trim()) return undefined
116122
parsed = Number(value.trim())
117123
}
118-
if (!Number.isFinite(parsed)) return undefined
124+
if (!Number.isFinite(parsed)) {
125+
throw new Error(`GitHub line must be a valid number, but line was ${String(value)}`)
126+
}
119127
if (!Number.isInteger(parsed)) {
120128
throw new Error(
121129
`GitHub line numbers are whole numbers, but line was ${parsed}. Set line to the integer line number in the diff.`
@@ -128,13 +136,15 @@ function fileCommentBody(
128136
params: CreateCommentParams,
129137
commitId: string | undefined
130138
): FileCommentBody {
131-
return {
139+
const base = {
132140
body: params.body,
133141
commit_id: commitId,
134142
path: params.path,
135-
line: toLineNumber(params.line),
136-
side: params.side || 'RIGHT',
137143
}
144+
const line = toLineNumber(params.line)
145+
if (line === undefined) return { ...base, subject_type: 'file' }
146+
if (line < 1) throw new Error('GitHub line numbers must be positive integers')
147+
return { ...base, line, side: params.side || 'RIGHT' }
138148
}
139149

140150
function commentEndpointUrl(params: CreateCommentParams): string {
@@ -169,6 +179,7 @@ function readNumber(record: Record<string, unknown>, key: string): number | unde
169179

170180
function readCommentPayload(value: unknown): GitHubCommentPayload {
171181
if (!isRecordLike(value)) return {}
182+
const submittedAt = readString(value, 'submitted_at')
172183
return {
173184
id: readNumber(value, 'id'),
174185
body: readString(value, 'body'),
@@ -179,8 +190,8 @@ function readCommentPayload(value: unknown): GitHubCommentPayload {
179190
position: readNumber(value, 'position'),
180191
side: readString(value, 'side'),
181192
commit_id: readString(value, 'commit_id'),
182-
created_at: readString(value, 'created_at'),
183-
updated_at: readString(value, 'updated_at'),
193+
created_at: readString(value, 'created_at') ?? submittedAt,
194+
updated_at: readString(value, 'updated_at') ?? submittedAt,
184195
}
185196
}
186197

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { executeAddUserAppRoleAssignmentOperation } from '@/lib/internal/microsoft-ad/operations/add-user-app-role-assignment'
6+
7+
const INPUT = {
8+
accessToken: 'access-token',
9+
userId: '11111111-1111-4111-8111-111111111111',
10+
resourceId: '22222222-2222-4222-8222-222222222222',
11+
appRoleId: '33333333-3333-4333-8333-333333333333',
12+
}
13+
14+
describe('executeAddUserAppRoleAssignmentOperation', () => {
15+
const fetchMock = vi.fn()
16+
17+
beforeEach(() => {
18+
fetchMock.mockReset()
19+
vi.stubGlobal('fetch', fetchMock)
20+
})
21+
22+
afterEach(() => vi.unstubAllGlobals())
23+
24+
it('rejects malformed successful Graph JSON instead of fabricating a null assignment', async () => {
25+
fetchMock.mockResolvedValueOnce(new Response('not-json'))
26+
27+
await expect(executeAddUserAppRoleAssignmentOperation(INPUT)).rejects.toThrow(
28+
'Microsoft Graph returned malformed JSON for the app role assignment'
29+
)
30+
expect(fetchMock).toHaveBeenCalledTimes(1)
31+
})
32+
33+
it('rejects a successful non-object assignment payload', async () => {
34+
fetchMock.mockResolvedValueOnce(Response.json(null))
35+
36+
await expect(executeAddUserAppRoleAssignmentOperation(INPUT)).rejects.toThrow(
37+
'Microsoft Graph returned an invalid app role assignment'
38+
)
39+
})
40+
41+
it('rejects a successful empty assignment payload', async () => {
42+
fetchMock.mockResolvedValueOnce(Response.json({}))
43+
44+
await expect(executeAddUserAppRoleAssignmentOperation(INPUT)).rejects.toThrow(
45+
'Microsoft Graph returned an invalid app role assignment'
46+
)
47+
})
48+
})

apps/sim/lib/internal/microsoft-ad/operations/add-user-app-role-assignment.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isRecordLike } from '@sim/utils/object'
12
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
23
import {
34
mapAppRoleAssignment,
@@ -27,14 +28,23 @@ export const executeAddUserAppRoleAssignmentOperation: InternalToolOperationImpl
2728
signal,
2829
}
2930
)
30-
const body = await response.json().catch(() => {
31+
let body: unknown
32+
try {
33+
body = await response.json()
34+
} catch {
3135
signal?.throwIfAborted()
32-
return {}
33-
})
36+
if (response.ok) {
37+
throw new Error('Microsoft Graph returned malformed JSON for the app role assignment')
38+
}
39+
body = {}
40+
}
3441
signal?.throwIfAborted()
3542
if (!response.ok) {
3643
throw new Error(extractGraphErrorMessage(body, 'Failed to grant the app role to the user'))
3744
}
45+
if (!isRecordLike(body) || typeof body.id !== 'string' || !body.id.trim()) {
46+
throw new Error('Microsoft Graph returned an invalid app role assignment')
47+
}
3848

3949
return { success: true, output: { assignment: mapAppRoleAssignment(body) } }
4050
}

apps/sim/lib/internal/salesforce/operations/update-custom-field.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,16 @@ describe('salesforce update custom field operation', () => {
7171
)
7272
expect(fetchMock).toHaveBeenCalledTimes(1)
7373
})
74+
75+
it('rejects a field ID that could escape the CustomField path before provider work', async () => {
76+
const fetchMock = vi.mocked(fetch)
77+
78+
await expect(
79+
executeSalesforceUpdateCustomFieldOperation({
80+
...PARAMS,
81+
fieldId: '../CustomObject',
82+
} as never)
83+
).rejects.toThrow('Field ID must be a 15- or 18-character Salesforce record ID')
84+
expect(fetchMock).not.toHaveBeenCalled()
85+
})
7486
})

apps/sim/lib/internal/salesforce/operations/update-custom-field.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,17 @@ import {
1313
} from '@/tools/salesforce/utils'
1414

1515
const logger = createLogger('SalesforceUpdateCustomField')
16+
const SALESFORCE_RECORD_ID_PATTERN = /^[A-Za-z0-9]{15}(?:[A-Za-z0-9]{3})?$/
1617

1718
export const executeSalesforceUpdateCustomFieldOperation: InternalToolOperationImplementation<
1819
SalesforceUpdateCustomFieldParams
1920
> = async (params, signal): Promise<SalesforceUpdateCustomFieldResponse> => {
2021
const instanceUrl = getInstanceUrl(params.idToken, params.instanceUrl)
2122
const fieldId = requireId(params.fieldId, 'Field ID')
22-
const url = `${instanceUrl}/services/data/v59.0/tooling/sobjects/CustomField/${fieldId}`
23+
if (!SALESFORCE_RECORD_ID_PATTERN.test(fieldId)) {
24+
throw new Error('Field ID must be a 15- or 18-character Salesforce record ID')
25+
}
26+
const url = `${instanceUrl}/services/data/v59.0/tooling/sobjects/CustomField/${encodeURIComponent(fieldId)}`
2327
const headers = {
2428
Authorization: `Bearer ${params.accessToken}`,
2529
'Content-Type': 'application/json',

apps/sim/lib/internal/supabase/operations/storage-update-bucket.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,36 @@ describe('executeStorageUpdateBucketOperation', () => {
4949
expect(payload.file_size_limit).toBe(4096)
5050
})
5151

52+
it('rejects a nonnumeric file limit before updating the bucket', async () => {
53+
fetchMock.mockResolvedValueOnce(Response.json({ public: false, file_size_limit: 4096 }))
54+
55+
const result = await executeStorageUpdateBucketOperation({
56+
...INPUT,
57+
fileSizeLimit: 'not-a-number' as never,
58+
})
59+
60+
expect(result).toMatchObject({
61+
success: false,
62+
error: 'File size limit must be a finite number',
63+
})
64+
expect(fetchMock).toHaveBeenCalledTimes(1)
65+
})
66+
67+
it.each([true, [], {}, '0x100'])('rejects a non-decimal file limit %j', async (fileSizeLimit) => {
68+
fetchMock.mockResolvedValueOnce(Response.json({ public: false, file_size_limit: 4096 }))
69+
70+
const result = await executeStorageUpdateBucketOperation({
71+
...INPUT,
72+
fileSizeLimit: fileSizeLimit as never,
73+
})
74+
75+
expect(result).toMatchObject({
76+
success: false,
77+
error: 'File size limit must be a finite number',
78+
})
79+
expect(fetchMock).toHaveBeenCalledTimes(1)
80+
})
81+
5282
it('propagates cancellation instead of returning a failed tool envelope', async () => {
5383
const controller = new AbortController()
5484
fetchMock.mockImplementationOnce(async (_url, init) => {

0 commit comments

Comments
 (0)