Skip to content

Commit 5ae4dcd

Browse files
authored
fix(tools): stop provider timeout params from becoming the request deadline (#8878)
* fix(tools): stop provider timeout params from becoming the request deadline The request transport and the internal-operation path read params.timeout as a millisecond deadline for every tool. Twilio make_call, New Relic NRQL, Apify (3 tools), Daytona (2 tools), and Trigger.dev waitpoint tokens declare their own timeout param in seconds or as a duration, so a 60-second setting aborted the call after 60 ms. A declared timeout param is now the deadline only when the tool sets timeoutParamIsDeadline (http_request, firecrawl_map, firecrawl_parse); callers can still bound tools that declare none. No param ids change. Redis and Upstash coerced params with Number() inside tools.config.tool, which runs at serialization on the serialized params object, turning <Block.output> references into NaN. The coercions now run in tools.config.params. Guardrails: check-block-registry rejects coerced assignments to params inside an inline tools.config.tool; check-tool-param-reachability rejects a method param on a fixed-verb external tool, which the transport would send as the HTTP verb. * fix(audits): catch coercions under fallbacks in tools.config.tool, and clarify declared timeout params * fix(audits): treat every value-deriving expression as a selector coercion * fix(audits): reject compound assignments and increments on params in selectors
1 parent cf4e2d4 commit 5ae4dcd

11 files changed

Lines changed: 247 additions & 27 deletions

File tree

‎.agents/skills/add-tools/SKILL.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,8 @@ fallback, or caller-controlled `_context` authority.
190190

191191
A required `'hidden'` param needs an `oauth` declaration or `hosting.apiKeyParam` to supply it (`bun run check:tool-param-reachability`).
192192

193+
A declared `timeout` param is an ordinary tool input — put it in the request body or URL yourself if the provider expects it; it becomes Sim's millisecond request deadline only when the tool sets `timeoutParamIsDeadline: true` (`http_request`). A `method` param on a tool with a fixed `request.method` would be sent as the HTTP verb, so the same audit rejects it.
194+
193195
### Parameter Types
194196
- `'string'` - Text values
195197
- `'number'` - Numeric values

‎apps/sim/blocks/blocks/redis.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -255,23 +255,25 @@ export const RedisBlock: BlockConfig = {
255255
'redis_setnx',
256256
],
257257
config: {
258-
tool: (params) => {
258+
tool: (params) => `redis_${params.operation}`,
259+
params: (params) => {
260+
const coerced: Record<string, number> = {}
259261
if (params.ex) {
260-
params.ex = Number(params.ex)
262+
coerced.ex = Number(params.ex)
261263
}
262264
if (params.seconds !== undefined) {
263-
params.seconds = Number(params.seconds)
265+
coerced.seconds = Number(params.seconds)
264266
}
265267
if (params.start !== undefined) {
266-
params.start = Number(params.start)
268+
coerced.start = Number(params.start)
267269
}
268270
if (params.stop !== undefined) {
269-
params.stop = Number(params.stop)
271+
coerced.stop = Number(params.stop)
270272
}
271273
if (params.increment !== undefined) {
272-
params.increment = Number(params.increment)
274+
coerced.increment = Number(params.increment)
273275
}
274-
return `redis_${params.operation}`
276+
return coerced
275277
},
276278
},
277279
},

‎apps/sim/blocks/blocks/upstash.ts‎

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -255,21 +255,6 @@ export const UpstashBlock: BlockConfig = {
255255
],
256256
config: {
257257
tool: (params) => {
258-
if (params.ex) {
259-
params.ex = Number(params.ex)
260-
}
261-
if (params.seconds !== undefined) {
262-
params.seconds = Number(params.seconds)
263-
}
264-
if (params.start !== undefined) {
265-
params.start = Number(params.start)
266-
}
267-
if (params.stop !== undefined) {
268-
params.stop = Number(params.stop)
269-
}
270-
if (params.increment !== undefined) {
271-
params.increment = Number(params.increment)
272-
}
273258
switch (params.operation) {
274259
case 'get':
275260
return 'upstash_redis_get'
@@ -307,6 +292,25 @@ export const UpstashBlock: BlockConfig = {
307292
throw new Error(`Unknown operation: ${params.operation}`)
308293
}
309294
},
295+
params: (params) => {
296+
const coerced: Record<string, number> = {}
297+
if (params.ex) {
298+
coerced.ex = Number(params.ex)
299+
}
300+
if (params.seconds !== undefined) {
301+
coerced.seconds = Number(params.seconds)
302+
}
303+
if (params.start !== undefined) {
304+
coerced.start = Number(params.start)
305+
}
306+
if (params.stop !== undefined) {
307+
coerced.stop = Number(params.stop)
308+
}
309+
if (params.increment !== undefined) {
310+
coerced.increment = Number(params.increment)
311+
}
312+
return coerced
313+
},
310314
},
311315
},
312316
inputs: {

‎apps/sim/scripts/check-block-registry.ts‎

Lines changed: 150 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
/**
44
* CI check: enforces block-registry invariants that protect the runtime.
55
*
6-
* Two checks run in sequence:
6+
* Checks run in sequence:
77
*
88
* 1. **Subblock ID stability** — diffs the current registry against a base ref
99
* and fails if any subblock ID was removed without a corresponding entry in
@@ -17,6 +17,18 @@
1717
* resolve values via direct lookup; mismatches false-flag fields as missing
1818
* at submit time.
1919
*
20+
* 3. **Selector coercions** — a block's inline `tools.config.tool` may not
21+
* assign a coerced value (`Number(...)`, `parseInt(...)`, `JSON.parse(...)`,
22+
* `!!x`, unary `+`, a template literal, a comparison, …) back onto its params,
23+
* nor write to a param with a compound assignment (`+=`, `??=`, …) or `++`/`--`.
24+
* `selectToolId` runs the selector during serialization on the object that
25+
* becomes the serialized block's `params`, before `<Block.output>` references
26+
* resolve, so `params.x = Number(params.x)` turns a reference into `NaN`.
27+
* Coercions belong in `tools.config.params`, which the executor runs on
28+
* resolved inputs.
29+
*
30+
* Integration BlockMeta coverage and sunset `replacedBy` targets are checked too.
31+
*
2032
* Usage:
2133
* bun run apps/sim/scripts/check-block-registry.ts [base-ref]
2234
*
@@ -25,6 +37,9 @@
2537
*/
2638

2739
import { execFileSync } from 'node:child_process'
40+
import { readdirSync, readFileSync } from 'node:fs'
41+
import { join } from 'node:path'
42+
import ts from '@typescript/typescript6'
2843
import { SUBBLOCK_ID_MIGRATIONS } from '@/lib/workflows/migrations/subblock-migrations'
2944
import { getAllBlocks, getBlock, getBlockMeta, getBlockRegistry } from '@/blocks/registry'
3045
import { readBlockRegistryAtRef } from '@/scripts/block-registry-snapshot'
@@ -231,6 +246,132 @@ function checkSunsetReplacedBy(): CheckResult {
231246
return { kind: 'fail', errors }
232247
}
233248

249+
const COERCION_CALLEES = new Set([
250+
'BigInt',
251+
'Boolean',
252+
'JSON.parse',
253+
'Number',
254+
'Number.parseFloat',
255+
'Number.parseInt',
256+
'String',
257+
'parseFloat',
258+
'parseInt',
259+
])
260+
261+
const COERCING_UNARY = new Set([
262+
ts.SyntaxKind.PlusToken,
263+
ts.SyntaxKind.MinusToken,
264+
ts.SyntaxKind.ExclamationToken,
265+
ts.SyntaxKind.TildeToken,
266+
])
267+
268+
const COMPARISON_OPERATORS = new Set([
269+
ts.SyntaxKind.EqualsEqualsToken,
270+
ts.SyntaxKind.EqualsEqualsEqualsToken,
271+
ts.SyntaxKind.ExclamationEqualsToken,
272+
ts.SyntaxKind.ExclamationEqualsEqualsToken,
273+
ts.SyntaxKind.LessThanToken,
274+
ts.SyntaxKind.LessThanEqualsToken,
275+
ts.SyntaxKind.GreaterThanToken,
276+
ts.SyntaxKind.GreaterThanEqualsToken,
277+
])
278+
279+
/**
280+
* Whether `expression` derives a new value anywhere in it — a coercing call, a unary `+ - ! ~`,
281+
* a template literal with substitutions, or a comparison — including under `??`, `||`, or a
282+
* conditional. Any of these turns an unresolved `<Block.output>` reference into a fixed value.
283+
*/
284+
function isCoercion(expression: ts.Node): boolean {
285+
if (ts.isPrefixUnaryExpression(expression) && COERCING_UNARY.has(expression.operator)) return true
286+
if (ts.isCallExpression(expression) && COERCION_CALLEES.has(expression.expression.getText())) {
287+
return true
288+
}
289+
if (ts.isTemplateExpression(expression)) return true
290+
if (
291+
ts.isBinaryExpression(expression) &&
292+
COMPARISON_OPERATORS.has(expression.operatorToken.kind)
293+
) {
294+
return true
295+
}
296+
return ts.forEachChild(expression, (child) => (isCoercion(child) ? true : undefined)) ?? false
297+
}
298+
299+
function findSelectorCoercions(file: string, source: ts.SourceFile): string[] {
300+
const errors: string[] = []
301+
const visit = (node: ts.Node) => {
302+
const selector =
303+
ts.isPropertyAssignment(node) || ts.isMethodDeclaration(node) ? node : undefined
304+
const config = selector?.parent.parent
305+
if (
306+
selector &&
307+
ts.isIdentifier(selector.name) &&
308+
selector.name.text === 'tool' &&
309+
config &&
310+
ts.isPropertyAssignment(config) &&
311+
ts.isIdentifier(config.name) &&
312+
config.name.text === 'config'
313+
) {
314+
const fn = ts.isMethodDeclaration(selector) ? selector : selector.initializer
315+
const paramsName =
316+
(ts.isArrowFunction(fn) || ts.isFunctionExpression(fn) || ts.isMethodDeclaration(fn)) &&
317+
fn.parameters[0] &&
318+
ts.isIdentifier(fn.parameters[0].name)
319+
? fn.parameters[0].name.text
320+
: undefined
321+
const isParamsMember = (target: ts.Expression) =>
322+
(ts.isPropertyAccessExpression(target) || ts.isElementAccessExpression(target)) &&
323+
ts.isIdentifier(target.expression) &&
324+
target.expression.text === paramsName
325+
const findAssignments = (inner: ts.Node) => {
326+
const operator = ts.isBinaryExpression(inner) ? inner.operatorToken.kind : undefined
327+
const derivedWrite =
328+
(ts.isBinaryExpression(inner) &&
329+
isParamsMember(inner.left) &&
330+
((operator === ts.SyntaxKind.EqualsToken && isCoercion(inner.right)) ||
331+
(operator !== undefined &&
332+
operator >= ts.SyntaxKind.FirstCompoundAssignment &&
333+
operator <= ts.SyntaxKind.LastCompoundAssignment))) ||
334+
((ts.isPrefixUnaryExpression(inner) || ts.isPostfixUnaryExpression(inner)) &&
335+
(inner.operator === ts.SyntaxKind.PlusPlusToken ||
336+
inner.operator === ts.SyntaxKind.MinusMinusToken) &&
337+
isParamsMember(inner.operand))
338+
if (derivedWrite) {
339+
const line = source.getLineAndCharacterOfPosition(inner.getStart()).line + 1
340+
errors.push(
341+
`${file}:${line}: \`${inner.getText()}\` coerces a param inside tools.config.tool.\n` +
342+
' → Return the coerced value from tools.config.params instead.'
343+
)
344+
}
345+
ts.forEachChild(inner, findAssignments)
346+
}
347+
if (paramsName) findAssignments(fn)
348+
}
349+
ts.forEachChild(node, visit)
350+
}
351+
visit(source)
352+
return errors
353+
}
354+
355+
function checkSelectorCoercions(): CheckResult {
356+
const blocksDir = join(gitRoot, 'apps/sim/blocks/blocks')
357+
const errors: string[] = []
358+
for (const name of readdirSync(blocksDir)) {
359+
if (!name.endsWith('.ts') || name.endsWith('.test.ts')) continue
360+
const path = join(blocksDir, name)
361+
const source = ts.createSourceFile(
362+
path,
363+
readFileSync(path, 'utf-8'),
364+
ts.ScriptTarget.Latest,
365+
true
366+
)
367+
errors.push(...findSelectorCoercions(`apps/sim/blocks/blocks/${name}`, source))
368+
}
369+
if (errors.length === 0) {
370+
return { kind: 'pass', message: 'Selector coercion check passed' }
371+
}
372+
return { kind: 'fail', errors }
373+
}
374+
234375
function reportResult(label: string, failureHeader: string, result: CheckResult): boolean {
235376
if (result.kind === 'pass') {
236377
console.log(`✓ ${result.message}`)
@@ -252,6 +393,7 @@ const stabilityResult = checkSubblockIdStability()
252393
const canonicalResult = checkCanonicalIdContract()
253394
const metaCoverageResult = checkIntegrationMetaCoverage()
254395
const sunsetResult = checkSunsetReplacedBy()
396+
const selectorCoercionResult = checkSelectorCoercions()
255397

256398
const stabilityOk = reportResult(
257399
'Subblock ID stability check',
@@ -277,4 +419,10 @@ const sunsetOk = reportResult(
277419
sunsetResult
278420
)
279421

280-
process.exit(stabilityOk && canonicalOk && metaCoverageOk && sunsetOk ? 0 : 1)
422+
const selectorCoercionOk = reportResult(
423+
'Selector coercion check',
424+
'tools.config.tool runs at serialization, before references resolve, so a coerced reference becomes NaN.',
425+
selectorCoercionResult
426+
)
427+
428+
process.exit(stabilityOk && canonicalOk && metaCoverageOk && sunsetOk && selectorCoercionOk ? 0 : 1)

‎apps/sim/tools/firecrawl/map.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ export const mapTool: ToolConfig<MapParams, MapResponse> = {
6969

7070
hosting: firecrawlHosting(),
7171

72+
timeoutParamIsDeadline: true,
73+
7274
request: {
7375
method: 'POST',
7476
url: 'https://api.firecrawl.dev/v2/map',

‎apps/sim/tools/firecrawl/parse.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ export const parseTool: InternalToolConfig<ParseParams, ParseResponse> = {
9494

9595
hosting: firecrawlHosting(),
9696

97+
timeoutParamIsDeadline: true,
98+
9799
operation: {
98100
modelInput: {
99101
mode: 'project',

‎apps/sim/tools/http/request.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
9696
},
9797
},
9898

99+
timeoutParamIsDeadline: true,
100+
99101
request: {
100102
allowSameOrigin: true,
101103
url: (params: RequestParams) => {

‎apps/sim/tools/index.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ import {
113113
getOwnEnumerableDataEntries,
114114
prepareToolRequest,
115115
projectToolModelInputParams,
116+
readRequestedDeadline,
116117
} from '@/tools/request-transport'
117118
import type {
118119
BYOKProviderId,
@@ -2749,7 +2750,7 @@ async function executeDeclaredInternalOperation({
27492750
} else {
27502751
const handler = await getInternalToolOperationHandler(toolId)
27512752
if (!handler) throw new Error(`No internal operation registered for ${toolId}`)
2752-
const requestedTimeout = Number(params.timeout)
2753+
const requestedTimeout = Number(readRequestedDeadline(tool, params))
27532754
const operationTimeout =
27542755
Number.isFinite(requestedTimeout) && requestedTimeout > 0
27552756
? Math.min(requestedTimeout, getMaxExecutionTimeout())

‎apps/sim/tools/request-transport.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,18 @@ function collectProvenanceSensitiveHeaders(
155155
return [...sensitiveHeaders]
156156
}
157157

158+
/**
159+
* Reads the caller's millisecond deadline from `params.timeout`, unless the tool declares a
160+
* `timeout` param of its own without {@link ToolConfig.timeoutParamIsDeadline}.
161+
*/
162+
export function readRequestedDeadline(
163+
tool: ExecutableToolConfig,
164+
params: Record<string, unknown>
165+
): unknown {
166+
if (tool.params?.timeout && !tool.timeoutParamIsDeadline) return undefined
167+
return params.timeout
168+
}
169+
158170
function formatToolRequest(
159171
tool: ToolConfig,
160172
params: Record<string, any>,
@@ -189,7 +201,7 @@ function formatToolRequest(
189201
}
190202
}
191203

192-
const rawTimeout = params.timeout
204+
const rawTimeout = readRequestedDeadline(tool, params)
193205
const timeout = rawTimeout != null ? Number(rawTimeout) : undefined
194206
const validTimeout =
195207
timeout != null && Number.isFinite(timeout) && timeout > 0

‎apps/sim/tools/types.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,16 @@ export interface ToolConfig<P = any, R = any> {
321321
* Usage is billed according to the pricing config.
322322
*/
323323
hosting?: ToolHostingConfig<P>
324+
325+
/**
326+
* Makes this tool's declared `timeout` param the execution deadline, in milliseconds.
327+
*
328+
* A caller may pass `params.timeout` to bound any tool that does not declare one. A tool that
329+
* declares its own `timeout` param owns that value instead — usually a provider field in
330+
* seconds or a duration string, which as a millisecond deadline would abort the request almost
331+
* immediately — so the executor treats it as the deadline only when this is set.
332+
*/
333+
timeoutParamIsDeadline?: true
324334
}
325335

326336
export interface TableRow {

0 commit comments

Comments
 (0)