From 26aa582f3f15b320dfec57ebf5e3a8f057199813 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 16 Sep 2026 21:10:57 -0700 Subject: [PATCH 1/2] fix(http): substitute path parameters only by their whole name The HTTP request tool replaced each path parameter with a plain string replace of `:${key}` over the whole URL. An empty key stripped the colon from the scheme, so `https://host` became `https//host` and the request was refused as not absolute; a numeric key rewrote the port into the host; and `:id` matched inside `:idx`. Models occasionally send an empty path-parameter entry, which made agent API tool calls fail intermittently. Substitute a key only when it starts like a JavaScript identifier, as path-to-regexp defines `:name` parameters, and end each placeholder where an identifier ends. --- apps/sim/tools/http/request.test.ts | 28 +++++++++++++++++++++++++++ apps/sim/tools/http/utils.ts | 30 ++++++++++++++++++++++++++--- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/apps/sim/tools/http/request.test.ts b/apps/sim/tools/http/request.test.ts index 661239c7cbd..22d827bac4e 100644 --- a/apps/sim/tools/http/request.test.ts +++ b/apps/sim/tools/http/request.test.ts @@ -70,6 +70,34 @@ describe('HTTP Request Tool', () => { expect(url.includes('chars')).toBe(true) }) + it.concurrent('substitutes path parameters only by their whole name', () => { + expect(processUrl('https://www.google.com', { '': '' })).toBe('https://www.google.com') + expect(processUrl('https://api.example.com:8443/users/:id', { '8443': 'x', id: '42' })).toBe( + 'https://api.example.com:8443/users/42' + ) + expect(processUrl('https://api.example.com/users/:idx/:id', { id: '1', idx: '2' })).toBe( + 'https://api.example.com/users/2/1' + ) + expect(processUrl('https://api.example.com/users/:id-profile', { id: '7' })).toBe( + 'https://api.example.com/users/7-profile' + ) + expect(processUrl('https://api.example.com/users/:user-id', { 'user-id': '9' })).toBe( + 'https://api.example.com/users/9' + ) + expect(processUrl('https://api.example.com/users/:id', { id: '$&' })).toBe( + 'https://api.example.com/users/%24%26' + ) + expect(processUrl('https://api.example.com/users/:user.name', { 'user.name': 'ada' })).toBe( + 'https://api.example.com/users/ada' + ) + expect(processUrl('https://api.example.com/v1/:$ref', { $ref: 'x' })).toBe( + 'https://api.example.com/v1/x' + ) + expect(processUrl('https://api.example.com/users/:id', { '/': 'x', '1': 'y' })).toBe( + 'https://api.example.com/users/:id' + ) + }) + it.concurrent('canonicalizes first-party API calls before the apex redirect', () => { expect( processUrl('https://sim.ai/api/v2/workflows', undefined, [ diff --git a/apps/sim/tools/http/utils.ts b/apps/sim/tools/http/utils.ts index 22cba3b2647..a91dd60c6b5 100644 --- a/apps/sim/tools/http/utils.ts +++ b/apps/sim/tools/http/utils.ts @@ -1,3 +1,4 @@ +import { escapeRegExp } from '@/executor/constants' import { transformTable } from '@/tools/shared/table' import type { TableRow } from '@/tools/types' @@ -81,6 +82,31 @@ export const getDefaultHeaders = ( return headers } +/** + * A path parameter key must start like a JavaScript identifier, the way `:name` placeholders are + * defined by path-to-regexp. That excludes an empty key and one starting with a digit or `/`, the + * shapes that matched the scheme separator or a port; the rest of the key is left as callers use it. + */ +const PATH_PARAM_KEY = /^[A-Za-z_$][^\s/?#]*$/ + +/** + * Replaces the first `:key` placeholder for each path parameter with its URL-encoded value. + * + * A placeholder ends where an identifier would, so `:id` never matches inside `:idx`. A plain + * string replace let an empty key strip the scheme's colon (`https://` became `https//`), a + * numeric key rewrite a port, and `:id` match inside `:idx`. + */ +function substitutePathParams(url: string, pathParams: Record): string { + let substituted = url + for (const [key, value] of Object.entries(pathParams)) { + if (!PATH_PARAM_KEY.test(key)) continue + substituted = substituted.replace(new RegExp(`:${escapeRegExp(key)}(?![\\w$])`), () => + encodeURIComponent(value) + ) + } + return substituted +} + /** * Processes a URL with path parameters and query parameters * @param url Base URL to process @@ -98,9 +124,7 @@ export const processUrl = ( } if (pathParams) { - Object.entries(pathParams).forEach(([key, value]) => { - url = url.replace(`:${key}`, encodeURIComponent(value)) - }) + url = substitutePathParams(url, pathParams) } if (queryParams) { From 185a8e498369d84e9464493765da5a0be971288c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 16 Sep 2026 21:21:27 -0700 Subject: [PATCH 2/2] fix(http): prefer the longest path parameter key and accept Unicode identifiers --- apps/sim/tools/http/request.test.ts | 9 +++++++++ apps/sim/tools/http/utils.ts | 28 +++++++++++++++++----------- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/apps/sim/tools/http/request.test.ts b/apps/sim/tools/http/request.test.ts index 22d827bac4e..df59cabbd85 100644 --- a/apps/sim/tools/http/request.test.ts +++ b/apps/sim/tools/http/request.test.ts @@ -96,6 +96,15 @@ describe('HTTP Request Tool', () => { expect(processUrl('https://api.example.com/users/:id', { '/': 'x', '1': 'y' })).toBe( 'https://api.example.com/users/:id' ) + expect( + processUrl('https://api.example.com/:user-id/:user', { user: 'alice', 'user-id': '42' }) + ).toBe('https://api.example.com/42/alice') + expect( + processUrl('https://api.example.com/:user.name', { user: 'alice', 'user.name': 'ada' }) + ).toBe('https://api.example.com/ada') + expect(processUrl('https://api.example.com/:é/:éa', { é: '42', éa: '7' })).toBe( + 'https://api.example.com/42/7' + ) }) it.concurrent('canonicalizes first-party API calls before the apex redirect', () => { diff --git a/apps/sim/tools/http/utils.ts b/apps/sim/tools/http/utils.ts index a91dd60c6b5..0495fb3073c 100644 --- a/apps/sim/tools/http/utils.ts +++ b/apps/sim/tools/http/utils.ts @@ -83,25 +83,31 @@ export const getDefaultHeaders = ( } /** - * A path parameter key must start like a JavaScript identifier, the way `:name` placeholders are - * defined by path-to-regexp. That excludes an empty key and one starting with a digit or `/`, the - * shapes that matched the scheme separator or a port; the rest of the key is left as callers use it. + * A path parameter key must start like a JavaScript identifier, using the same character classes + * path-to-regexp uses for `:name` parameters. That excludes an empty key and one starting with a + * digit or `/`, the shapes that matched the scheme separator or a port; the rest of the key is left + * as callers use it. */ -const PATH_PARAM_KEY = /^[A-Za-z_$][^\s/?#]*$/ +const PATH_PARAM_KEY = /^[$_\p{ID_Start}][^\s/?#]*$/u +const PATH_PARAM_NAME_CONTINUE = '[$\\u200c\\u200d\\p{ID_Continue}]' /** * Replaces the first `:key` placeholder for each path parameter with its URL-encoded value. * - * A placeholder ends where an identifier would, so `:id` never matches inside `:idx`. A plain - * string replace let an empty key strip the scheme's colon (`https://` became `https//`), a - * numeric key rewrite a port, and `:id` match inside `:idx`. + * A placeholder ends where an identifier would, so `:id` never matches inside `:idx`, and longer + * keys are substituted first, so `:user-id` is not consumed by a `user` key. A plain string replace + * let an empty key strip the scheme's colon (`https://` became `https//`), a numeric key rewrite a + * port, and `:id` match inside `:idx`. */ function substitutePathParams(url: string, pathParams: Record): string { + const entries = Object.entries(pathParams) + .filter(([key]) => PATH_PARAM_KEY.test(key)) + .sort(([a], [b]) => b.length - a.length) let substituted = url - for (const [key, value] of Object.entries(pathParams)) { - if (!PATH_PARAM_KEY.test(key)) continue - substituted = substituted.replace(new RegExp(`:${escapeRegExp(key)}(?![\\w$])`), () => - encodeURIComponent(value) + for (const [key, value] of entries) { + substituted = substituted.replace( + new RegExp(`:${escapeRegExp(key)}(?!${PATH_PARAM_NAME_CONTINUE})`, 'u'), + () => encodeURIComponent(value) ) } return substituted