mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-29 18:47:25 +08:00
fix(integrations): repair broken endpoints, silent failures, and a path traversal (#7096)
* fix(integrations): repair broken endpoints, silent failures, and a path traversal - qdrant: search_vector returned the /points/query envelope instead of the points array, so downstream blocks saw an object where an array was declared - elasticsearch: search/count/create_index silently swallowed malformed JSON — count fell back to counting the entire index, search to match_all - serper: only 4 of 6 advertised verticals were mapped; videos and shopping returned empty (still billed) result sets. Replaced the if/else chain with a vertical dispatch table that hard-fails on an unknown type - enrow: find_email flattened the nested info object incorrectly, dropping firstname/lastname, and advertised a linkedin_url the API never returns - linkedin: share_post read postId from an empty body; /v2/ugcPosts returns it in the x-restli-id header - vercel: edge config endpoints moved to /v1/global-config - vercel: encode edgeConfigId so a traversing value cannot escape the base path - sixtyfour: enrich endpoints moved to /people-intelligence and /company-intelligence - langsmith: hard-coded API host consolidated to one constant, added missing non-ok guards, encoded run ids, capped echoed upstream error bodies - daytona: file upload moved to /files/upload-v2 - memory: PUT persisted a bare object where POST and the declared type both use an array Adds 53 tests across the affected tools. * fix(integrations): close path traversal fleet-wide, stop a credential reaching the wire Follows up the review round on this branch. Security: - The previous encodeURIComponent-only guard was incomplete. '.' and '..' are unreserved, so they survive encoding and the URL parser then removes them as dot segments — popping one path segment on a fixed host with the caller's bearer token still attached, including on DELETE. Adds a shared safeUrlPathSegment helper that rejects empty, '.', '..', and any residual path separator, and applies it across every Vercel and Daytona tool that interpolates an LLM-writable id into a request path - langsmith: create_run and create_runs_batch spread the whole params object into the request body, so the LangSmith API key was sent to LangSmith and stored in the run record. Request bodies are now built from an explicit allowlist of run-ingest fields, so an unlisted param cannot reach the wire Correctness: - langsmith: the run-payload normalizer was not idempotent and ran once in request.body and again in transformResponse, so a caller who left Run ID blank got back an id that was never sent. Downstream update_run/create_feedback wired to it would 404 - langsmith: batch patch entries were normalized as if they were new runs, minting ids and overwriting start_time/trace_id/dotted_order - langsmith: the 500-char error cap appended its ellipsis after slicing, so the advertised bound was actually 503 - serper: scholar and patents mapped a date field neither vertical returns, and a test asserted it. Their organic response key is now confirmed against Serper's published per-vertical examples rather than assumed - serper: an unknown vertical derived from the response URL turned a successful response into a thrown error; only a user-supplied type now hard-fails - linkedin: warn when a success status carries no x-restli-id header Also corrects the Vercel endpoint rationale in the PR description: the old /v1/edge-config path still routes and is not scheduled for removal, so the move to /v1/global-config is canonical alignment rather than a break-fix.
This commit is contained in:
@@ -32,7 +32,7 @@ Integrate Enrow to find verified B2B email addresses from a full name and compan
|
||||
|
||||
### Enrow Find Email
|
||||
|
||||
Find a verified B2B email address from a full name and company domain or name. Uses the Enrow async finder — submits a search and polls until the result is ready. Costs 1 credit per valid email found. (https://enrow.readme.io/reference/find-single-email)
|
||||
Find a verified B2B email address from a full name and company domain or name. Uses the Enrow async finder — submits a search and polls until the result is ready. Costs 1 credit per valid email found. (https://docs.enrow.io/api-reference/email-finder/find-single)
|
||||
|
||||
#### Input
|
||||
|
||||
@@ -51,13 +51,14 @@ Find a verified B2B email address from a full name and company domain or name. U
|
||||
| `email` | string | Email address found or verified |
|
||||
| `qualification` | string | Enrow quality result: "valid" or "invalid" |
|
||||
| `fullname` | string | Full name of the person searched |
|
||||
| `firstname` | string | First name of the person searched |
|
||||
| `lastname` | string | Last name of the person searched |
|
||||
| `company_name` | string | Company name associated with the result |
|
||||
| `company_domain` | string | Company domain associated with the result |
|
||||
| `linkedin_url` | string | LinkedIn profile URL of the person |
|
||||
|
||||
### Enrow Verify Email
|
||||
|
||||
Verify the deliverability of an email address using the Enrow async verifier. Submits a verification request and polls until the result is ready. Costs 0.25 credits per verification. (https://enrow.readme.io/reference/verify-single-email)
|
||||
Verify the deliverability of an email address using the Enrow async verifier. Submits a verification request and polls until the result is ready. Costs 0.25 credits per verification. (https://docs.enrow.io/api-reference/email-verifier/verify-single)
|
||||
|
||||
#### Input
|
||||
|
||||
|
||||
@@ -45,14 +45,14 @@ A powerful web search tool that provides access to Google search results through
|
||||
| `num` | number | No | Number of results to return \(e.g., 10, 20, 50\) |
|
||||
| `gl` | string | No | Country code for search results \(e.g., "us", "uk", "de", "fr"\) |
|
||||
| `hl` | string | No | Language code for search results \(e.g., "en", "es", "de", "fr"\) |
|
||||
| `type` | string | No | Type of search to perform \(e.g., "search", "news", "images", "videos", "places", "shopping"\) |
|
||||
| `type` | string | No | Type of search to perform. Must be one of "search", "news", "places", "images", "videos", "shopping", "scholar", "patents" — any other value is rejected. |
|
||||
| `apiKey` | string | Yes | Serper API Key |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `searchResults` | array | Search results with titles, links, snippets, and type-specific metadata \(date for news, rating for places, imageUrl for images\) |
|
||||
| `searchResults` | array | Search results with titles, links, snippets, and type-specific metadata \(date for news, rating for places, imageUrl for images, duration/source for videos, price/source for shopping\) |
|
||||
| ↳ `title` | string | Result title |
|
||||
| ↳ `link` | string | Result URL |
|
||||
| ↳ `snippet` | string | Result description/snippet |
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { memory } from '@sim/db/schema'
|
||||
import {
|
||||
createMockRequest,
|
||||
dbChainMockFns,
|
||||
hybridAuthMockFns,
|
||||
queueTableRows,
|
||||
resetDbChainMock,
|
||||
@@ -18,15 +19,21 @@ import {
|
||||
RESOLVED_SECRET_PROVENANCE_METADATA_V1,
|
||||
} from '@/lib/execution/private-tool-metadata'
|
||||
|
||||
const { mockCheckWorkspaceAccess } = vi.hoisted(() => ({
|
||||
const { mockCheckWorkspaceAccess, mockReplaceMemorySecretProvenanceInTx } = vi.hoisted(() => ({
|
||||
mockCheckWorkspaceAccess: vi.fn(),
|
||||
mockReplaceMemorySecretProvenanceInTx: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
checkWorkspaceAccess: mockCheckWorkspaceAccess,
|
||||
}))
|
||||
|
||||
import { GET } from '@/app/api/memory/[id]/route'
|
||||
vi.mock('@/lib/memory/secret-provenance', () => ({
|
||||
readBoundMemorySecretProvenance: vi.fn(() => ({ status: 'unknown' })),
|
||||
replaceMemorySecretProvenanceInTx: mockReplaceMemorySecretProvenanceInTx,
|
||||
}))
|
||||
|
||||
import { GET, PUT } from '@/app/api/memory/[id]/route'
|
||||
|
||||
const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const CONTEXT = { params: Promise.resolve({ id: 'missing-conversation' }) }
|
||||
@@ -89,3 +96,71 @@ describe('GET /api/memory/[id]', () => {
|
||||
expect(await response.json()).toEqual({ success: true, data: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /api/memory/[id]', () => {
|
||||
const CONVERSATION_ID = 'conversation-1'
|
||||
const PUT_CONTEXT = { params: Promise.resolve({ id: CONVERSATION_ID }) }
|
||||
const MESSAGE = { role: 'user', content: 'hi' } as const
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
authType: AuthType.API_KEY,
|
||||
})
|
||||
mockCheckWorkspaceAccess.mockResolvedValue({
|
||||
exists: true,
|
||||
hasAccess: true,
|
||||
canWrite: true,
|
||||
})
|
||||
mockReplaceMemorySecretProvenanceInTx.mockResolvedValue(undefined)
|
||||
dbChainMockFns.returning.mockResolvedValue([{ id: 'memory-row-1' }])
|
||||
queueTableRows(memory, [
|
||||
{ id: 'memory-row-1', key: CONVERSATION_ID, data: [], secretProvenanceVersion: null },
|
||||
])
|
||||
queueTableRows(memory, [
|
||||
{ id: 'memory-row-1', key: CONVERSATION_ID, data: [MESSAGE], secretProvenanceVersion: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
it('persists the updated message wrapped in an array so reads keep the POST-written shape', async () => {
|
||||
const response = await PUT(
|
||||
createMockRequest(
|
||||
'PUT',
|
||||
{ data: MESSAGE, workspaceId: WORKSPACE_ID },
|
||||
{},
|
||||
`http://localhost:3000/api/memory/${CONVERSATION_ID}`
|
||||
),
|
||||
PUT_CONTEXT
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(dbChainMockFns.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: [{ role: 'user', content: 'hi' }] })
|
||||
)
|
||||
})
|
||||
|
||||
it('hashes provenance over the exact persisted value', async () => {
|
||||
await PUT(
|
||||
createMockRequest(
|
||||
'PUT',
|
||||
{ data: MESSAGE, workspaceId: WORKSPACE_ID },
|
||||
{},
|
||||
`http://localhost:3000/api/memory/${CONVERSATION_ID}`
|
||||
),
|
||||
PUT_CONTEXT
|
||||
)
|
||||
|
||||
expect(mockReplaceMemorySecretProvenanceInTx).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'memory-row-1',
|
||||
[{ role: 'user', content: 'hi' }],
|
||||
expect.anything()
|
||||
)
|
||||
const [setPayload] = dbChainMockFns.set.mock.calls[0] as [{ data: unknown }]
|
||||
const [, , provenanceValue] = mockReplaceMemorySecretProvenanceInTx.mock.calls[0]
|
||||
expect(provenanceValue).toBe(setPayload.data)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -238,6 +238,14 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: Memory
|
||||
return memoryEnvelopeError(`Invalid agent memory data: ${errorMessage}`, 400)
|
||||
}
|
||||
|
||||
/**
|
||||
* POST stores memory data as an array (`route.ts`), and its `onConflictDoUpdate` relies on
|
||||
* jsonb append semantics that require an array on both sides. Every persisted row is therefore
|
||||
* an array, and `tools/memory/types.ts` declares `data: AgentMemoryData[]`. Wrap here so an
|
||||
* update never leaves behind a bare object that reads like `data[0]` would break on.
|
||||
*/
|
||||
const nextData = Array.isArray(validatedData) ? validatedData : [validatedData]
|
||||
|
||||
const now = new Date()
|
||||
const writeProvenance = resolveMemoryWriteSecretProvenance({
|
||||
request,
|
||||
@@ -251,7 +259,7 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: Memory
|
||||
const [updated] = await tx
|
||||
.update(memory)
|
||||
.set({
|
||||
data: validatedData,
|
||||
data: nextData,
|
||||
secretProvenanceVersion: writeProvenance.provenance
|
||||
? 1
|
||||
: existingMemories[0].secretProvenanceVersion,
|
||||
@@ -270,7 +278,7 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: Memory
|
||||
await replaceMemorySecretProvenanceInTx(
|
||||
tx,
|
||||
updated.id,
|
||||
validatedData,
|
||||
nextData,
|
||||
writeProvenance.provenance
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/
|
||||
import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
|
||||
import { assertToolFileAccess } from '@/app/api/files/authorization'
|
||||
import { DAYTONA_TOOLBOX_BASE_URL, extractDaytonaError } from '@/tools/daytona/utils'
|
||||
import { daytonaToolboxUrl, extractDaytonaError } from '@/tools/daytona/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -129,7 +129,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
fileName
|
||||
)
|
||||
|
||||
const uploadUrl = `${DAYTONA_TOOLBOX_BASE_URL}/${encodeURIComponent(params.sandboxId.trim())}/files/upload?path=${encodeURIComponent(destinationPath)}`
|
||||
const uploadUrl = daytonaToolboxUrl(
|
||||
params.sandboxId,
|
||||
`/files/upload-v2?path=${encodeURIComponent(destinationPath)}`
|
||||
)
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
@@ -128,9 +128,10 @@ export const EnrowBlock: BlockConfig<EnrowResponse> = {
|
||||
email: { type: 'string', description: 'Email address found or verified' },
|
||||
qualification: { type: 'string', description: '"valid" or "invalid"' },
|
||||
fullname: { type: 'string', description: 'Full name of the person (find only)' },
|
||||
firstname: { type: 'string', description: 'First name of the person (find only)' },
|
||||
lastname: { type: 'string', description: 'Last name of the person (find only)' },
|
||||
company_name: { type: 'string', description: 'Company name (find only)' },
|
||||
company_domain: { type: 'string', description: 'Company domain (find only)' },
|
||||
linkedin_url: { type: 'string', description: 'LinkedIn URL of the person (find only)' },
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ export const SerperBlock: BlockConfig<SearchResponse> = {
|
||||
{ label: 'news', id: 'news' },
|
||||
{ label: 'places', id: 'places' },
|
||||
{ label: 'images', id: 'images' },
|
||||
{ label: 'videos', id: 'videos' },
|
||||
{ label: 'shopping', id: 'shopping' },
|
||||
],
|
||||
value: () => 'search',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*
|
||||
* Guards the Daytona sandbox-scoped tools against path traversal through
|
||||
* `sandboxId`, which is `visibility: 'user-or-llm'` and therefore reachable by
|
||||
* prompt injection.
|
||||
*
|
||||
* `encodeURIComponent` alone did not close this: `.` and `..` are unreserved,
|
||||
* so they survive encoding and the URL parser then strips them as dot segments.
|
||||
* `https://proxy.app.daytona.io/toolbox/../files/upload-v2` resolves to
|
||||
* `https://proxy.app.daytona.io/files/upload-v2` with the caller's Daytona
|
||||
* bearer token still attached. Assertions resolve through `new URL(...)` — the
|
||||
* same normalization `fetch` performs — instead of matching the raw template,
|
||||
* because string matching is exactly what let this through.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as daytonaTools from '@/tools/daytona/index'
|
||||
import { daytonaToolboxUrl, encodeSandboxId } from '@/tools/daytona/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
const TOOLBOX_PREFIX = '/toolbox/'
|
||||
const API_PREFIX = '/api/sandbox/'
|
||||
|
||||
/**
|
||||
* The bare `.` and `..` entries are the whole point: their omission is why an
|
||||
* earlier `encodeURIComponent`-only fix looked correct while the hole was live.
|
||||
*/
|
||||
const TRAVERSAL_IDS = [
|
||||
'..',
|
||||
'.',
|
||||
' .. ',
|
||||
'../../files/upload-v2',
|
||||
'..%2f..%2ffiles',
|
||||
'sbx_abc/../../files',
|
||||
'sbx_abc?token=attacker',
|
||||
'sbx_abc#fragment',
|
||||
'\\..\\..',
|
||||
] as const
|
||||
|
||||
const LEGITIMATE_IDS = [
|
||||
'sbx_abc123',
|
||||
'3f2a9c1e-7b64-4d80-9f11-2c6a5e8b0d43',
|
||||
'my-sandbox',
|
||||
'sandbox.v2',
|
||||
'..foo',
|
||||
'foo..',
|
||||
] as const
|
||||
|
||||
const SAFE_ID = 'SAFEID'
|
||||
|
||||
type AnyTool = ToolConfig<any, any>
|
||||
|
||||
function isDaytonaTool(value: unknown): value is AnyTool {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as AnyTool).id === 'string' &&
|
||||
(value as AnyTool).id.startsWith('daytona_')
|
||||
)
|
||||
}
|
||||
|
||||
function buildParams(tool: AnyTool, sandboxId: string): Record<string, unknown> {
|
||||
const params: Record<string, unknown> = { apiKey: 'token', sandboxId }
|
||||
for (const [name, def] of Object.entries(tool.params ?? {})) {
|
||||
if (name === 'apiKey' || name === 'sandboxId') continue
|
||||
const type = (def as { type?: string }).type
|
||||
if (type === 'json' || type === 'array') {
|
||||
params[name] = []
|
||||
} else if (type === 'number') {
|
||||
params[name] = 1
|
||||
} else if (type === 'boolean') {
|
||||
params[name] = false
|
||||
} else {
|
||||
params[name] = 'value'
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
function buildPath(tool: AnyTool, sandboxId: string): string {
|
||||
const url = tool.request?.url
|
||||
if (typeof url !== 'function') {
|
||||
throw new Error(`${tool.id} does not build its URL from params`)
|
||||
}
|
||||
return new URL(url(buildParams(tool, sandboxId) as any)).pathname
|
||||
}
|
||||
|
||||
function segmentsOf(pathname: string): string[] {
|
||||
return pathname.split('/')
|
||||
}
|
||||
|
||||
const SANDBOX_SCOPED_TOOLS = Object.values(daytonaTools)
|
||||
.filter(isDaytonaTool)
|
||||
.filter((tool) => typeof tool.request?.url === 'function')
|
||||
.filter((tool) => {
|
||||
try {
|
||||
return buildPath(tool, SAFE_ID).includes(SAFE_ID)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
.map((tool) => ({ name: tool.id, tool }))
|
||||
|
||||
describe('daytona sandboxId traversal safety', () => {
|
||||
it('covers every sandbox-scoped Daytona tool', () => {
|
||||
expect(SANDBOX_SCOPED_TOOLS.length).toBeGreaterThanOrEqual(9)
|
||||
})
|
||||
|
||||
describe.each(SANDBOX_SCOPED_TOOLS)('$name', ({ tool }) => {
|
||||
const baseline = segmentsOf(buildPath(tool, SAFE_ID))
|
||||
|
||||
it('stays under a sandbox-scoped prefix for a benign id', () => {
|
||||
const path = buildPath(tool, SAFE_ID)
|
||||
|
||||
expect(path.startsWith(TOOLBOX_PREFIX) || path.startsWith(API_PREFIX)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(TRAVERSAL_IDS)('cannot reshape the path with %j', (sandboxId) => {
|
||||
let path: string
|
||||
try {
|
||||
path = buildPath(tool, sandboxId)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
expect(path.startsWith(TOOLBOX_PREFIX) || path.startsWith(API_PREFIX)).toBe(true)
|
||||
|
||||
const actual = segmentsOf(path)
|
||||
expect(actual).toHaveLength(baseline.length)
|
||||
baseline.forEach((segment, index) => {
|
||||
if (segment === SAFE_ID) return
|
||||
expect(actual[index]).toBe(segment)
|
||||
})
|
||||
})
|
||||
|
||||
it.each(LEGITIMATE_IDS)('passes %j through unchanged', (sandboxId) => {
|
||||
const actual = segmentsOf(buildPath(tool, sandboxId))
|
||||
|
||||
expect(actual).toHaveLength(baseline.length)
|
||||
baseline.forEach((segment, index) => {
|
||||
expect(actual[index]).toBe(segment === SAFE_ID ? sandboxId : segment)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('daytonaToolboxUrl', () => {
|
||||
it.each(['..', '.', ' .. '])('rejects the dot segment %j', (sandboxId) => {
|
||||
expect(() => daytonaToolboxUrl(sandboxId, '/files/upload-v2')).toThrow(/Sandbox ID/)
|
||||
})
|
||||
|
||||
it('rejects a traversal attempt that carries a path separator', () => {
|
||||
expect(() => daytonaToolboxUrl('sbx_abc/../../files', '/files/upload-v2')).toThrow(
|
||||
/Sandbox ID cannot contain a path separator/
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a dot-bearing id that is not a dot segment inside /toolbox/', () => {
|
||||
const url = new URL(daytonaToolboxUrl('sbx..abc', '/files/upload-v2'))
|
||||
|
||||
expect(url.origin).toBe('https://proxy.app.daytona.io')
|
||||
expect(url.pathname.startsWith(TOOLBOX_PREFIX)).toBe(true)
|
||||
expect(url.pathname).toBe('/toolbox/sbx..abc/files/upload-v2')
|
||||
})
|
||||
|
||||
it('builds the upload path the API route relies on', () => {
|
||||
const url = new URL(daytonaToolboxUrl('sbx_abc123', '/files/upload-v2?path=%2Ftmp%2Fa.txt'))
|
||||
|
||||
expect(url.pathname).toBe('/toolbox/sbx_abc123/files/upload-v2')
|
||||
expect(url.searchParams.get('path')).toBe('/tmp/a.txt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('encodeSandboxId', () => {
|
||||
it('rejects an empty id', () => {
|
||||
expect(() => encodeSandboxId(' ')).toThrow(/Sandbox ID/)
|
||||
})
|
||||
|
||||
it.each(LEGITIMATE_IDS)('leaves %j untouched', (sandboxId) => {
|
||||
expect(encodeSandboxId(sandboxId)).toBe(sandboxId)
|
||||
})
|
||||
})
|
||||
@@ -1,19 +1,21 @@
|
||||
import type { DaytonaSandboxSummary } from '@/tools/daytona/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
|
||||
export const DAYTONA_API_BASE_URL = 'https://app.daytona.io/api'
|
||||
|
||||
export const DAYTONA_TOOLBOX_BASE_URL = 'https://proxy.app.daytona.io/toolbox'
|
||||
|
||||
/**
|
||||
* Trims and URL-encodes a sandbox identifier, rejecting empty values so a
|
||||
* blank ID can never resolve to a different API route.
|
||||
* Trims and URL-encodes a sandbox identifier so it can only ever resolve to the
|
||||
* intended sandbox-scoped route.
|
||||
*
|
||||
* Delegates to {@link safeUrlPathSegment}, which additionally rejects the dot
|
||||
* segments `.` and `..`: percent-encoding leaves those intact and the URL
|
||||
* parser then normalizes them away, popping a path segment. See that helper's
|
||||
* documentation for the full explanation.
|
||||
*/
|
||||
export function encodeSandboxId(sandboxId: string): string {
|
||||
const trimmed = sandboxId?.trim()
|
||||
if (!trimmed) {
|
||||
throw new Error('Sandbox ID is required')
|
||||
}
|
||||
return encodeURIComponent(trimmed)
|
||||
return safeUrlPathSegment(sandboxId, 'Sandbox ID')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -119,7 +119,7 @@ export const countTool: ToolConfig<ElasticsearchCountParams, ElasticsearchCountR
|
||||
try {
|
||||
return { query: JSON.parse(params.query) }
|
||||
} catch {
|
||||
return {}
|
||||
throw new Error('Invalid JSON provided for query')
|
||||
}
|
||||
}
|
||||
return {}
|
||||
|
||||
@@ -127,7 +127,7 @@ export const createIndexTool: ToolConfig<
|
||||
try {
|
||||
body.settings = JSON.parse(params.settings)
|
||||
} catch {
|
||||
// Ignore invalid settings
|
||||
throw new Error('Invalid JSON provided for settings')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ export const createIndexTool: ToolConfig<
|
||||
try {
|
||||
body.mappings = JSON.parse(params.mappings)
|
||||
} catch {
|
||||
// Ignore invalid mappings
|
||||
throw new Error('Invalid JSON provided for mappings')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { countTool } from '@/tools/elasticsearch/count'
|
||||
import { createIndexTool } from '@/tools/elasticsearch/create_index'
|
||||
import { searchTool } from '@/tools/elasticsearch/search'
|
||||
import type {
|
||||
ElasticsearchCountParams,
|
||||
ElasticsearchCreateIndexParams,
|
||||
ElasticsearchSearchParams,
|
||||
} from '@/tools/elasticsearch/types'
|
||||
|
||||
const CONNECTION = {
|
||||
deploymentType: 'self_hosted',
|
||||
host: 'https://es.example.com',
|
||||
authMethod: 'api_key',
|
||||
apiKey: 'test-key',
|
||||
index: 'products',
|
||||
} as const
|
||||
|
||||
function searchBody(overrides: Partial<ElasticsearchSearchParams>): Record<string, unknown> {
|
||||
const build = searchTool.request.body
|
||||
if (!build) throw new Error('searchTool.request.body is not defined')
|
||||
return build({ ...CONNECTION, ...overrides } as ElasticsearchSearchParams) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
}
|
||||
|
||||
function countBody(overrides: Partial<ElasticsearchCountParams>): Record<string, unknown> {
|
||||
const build = countTool.request.body
|
||||
if (!build) throw new Error('countTool.request.body is not defined')
|
||||
return build({ ...CONNECTION, ...overrides } as ElasticsearchCountParams) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
}
|
||||
|
||||
function createIndexBody(
|
||||
overrides: Partial<ElasticsearchCreateIndexParams>
|
||||
): Record<string, unknown> {
|
||||
const build = createIndexTool.request.body
|
||||
if (!build) throw new Error('createIndexTool.request.body is not defined')
|
||||
return build({ ...CONNECTION, ...overrides } as ElasticsearchCreateIndexParams) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
}
|
||||
|
||||
describe('elasticsearch_search request body', () => {
|
||||
it('throws on malformed query JSON instead of silently scanning the whole index', () => {
|
||||
expect(() => searchBody({ query: '{"match":{title:"x"}}' })).toThrow(/Invalid JSON/)
|
||||
})
|
||||
|
||||
it('omits the query key entirely when no query is provided', () => {
|
||||
const body = searchBody({})
|
||||
expect(body).not.toHaveProperty('query')
|
||||
})
|
||||
|
||||
it('passes valid query JSON through unchanged', () => {
|
||||
const body = searchBody({ query: '{"match":{"title":"x"}}' })
|
||||
expect(body.query).toEqual({ match: { title: 'x' } })
|
||||
})
|
||||
|
||||
it('throws on malformed sort JSON instead of silently dropping it', () => {
|
||||
expect(() => searchBody({ sort: '[{created_at:"desc"}]' })).toThrow(/Invalid JSON/)
|
||||
})
|
||||
|
||||
it('passes valid sort JSON through unchanged', () => {
|
||||
const body = searchBody({ sort: '[{"created_at":"desc"}]' })
|
||||
expect(body.sort).toEqual([{ created_at: 'desc' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('elasticsearch_count request body', () => {
|
||||
it('throws on malformed query JSON instead of silently counting the whole index', () => {
|
||||
expect(() => countBody({ query: '{"match":{status:"active"}}' })).toThrow(/Invalid JSON/)
|
||||
})
|
||||
|
||||
it('omits the query key entirely when no query is provided', () => {
|
||||
const body = countBody({})
|
||||
expect(body).not.toHaveProperty('query')
|
||||
})
|
||||
|
||||
it('passes valid query JSON through unchanged', () => {
|
||||
const body = countBody({ query: '{"match":{"status":"active"}}' })
|
||||
expect(body.query).toEqual({ match: { status: 'active' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('elasticsearch_create_index request body', () => {
|
||||
it('throws on malformed settings JSON instead of creating a default index', () => {
|
||||
expect(() => createIndexBody({ settings: '{number_of_shards:1}' })).toThrow(/Invalid JSON/)
|
||||
})
|
||||
|
||||
it('throws on malformed mappings JSON instead of creating a default-mapped index', () => {
|
||||
expect(() => createIndexBody({ mappings: '{"properties":{title:{"type":"text"}}}' })).toThrow(
|
||||
/Invalid JSON/
|
||||
)
|
||||
})
|
||||
|
||||
it('omits settings and mappings when neither is provided', () => {
|
||||
const body = createIndexBody({})
|
||||
expect(body).not.toHaveProperty('settings')
|
||||
expect(body).not.toHaveProperty('mappings')
|
||||
})
|
||||
|
||||
it('passes valid settings and mappings through unchanged', () => {
|
||||
const body = createIndexBody({
|
||||
settings: '{"number_of_shards":1}',
|
||||
mappings: '{"properties":{"title":{"type":"text"}}}',
|
||||
})
|
||||
expect(body.settings).toEqual({ number_of_shards: 1 })
|
||||
expect(body.mappings).toEqual({ properties: { title: { type: 'text' } } })
|
||||
})
|
||||
})
|
||||
@@ -161,8 +161,7 @@ export const searchTool: ToolConfig<ElasticsearchSearchParams, ElasticsearchSear
|
||||
try {
|
||||
body.query = JSON.parse(params.query)
|
||||
} catch {
|
||||
// If not valid JSON, treat as simple match query
|
||||
body.query = { match_all: {} }
|
||||
throw new Error('Invalid JSON provided for query')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +172,7 @@ export const searchTool: ToolConfig<ElasticsearchSearchParams, ElasticsearchSear
|
||||
try {
|
||||
body.sort = JSON.parse(params.sort)
|
||||
} catch {
|
||||
// Ignore invalid sort
|
||||
throw new Error('Invalid JSON provided for sort')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Only `sleep` is stubbed because it is the sole `@sim/utils/helpers` export the
|
||||
* enrow tools import. `vi.importActual` is banned by CLAUDE.md, so the module is
|
||||
* replaced wholesale; if an enrow tool ever imports another helper the import
|
||||
* will be `undefined` and these tests fail loudly rather than silently.
|
||||
*/
|
||||
vi.mock('@sim/utils/helpers', () => ({ sleep: vi.fn().mockResolvedValue(undefined) }))
|
||||
|
||||
import { enrowFindEmailTool } from '@/tools/enrow/find_email'
|
||||
import type {
|
||||
EnrowFindEmailParams,
|
||||
EnrowFindEmailResponse,
|
||||
EnrowVerifyEmailParams,
|
||||
EnrowVerifyEmailResponse,
|
||||
} from '@/tools/enrow/types'
|
||||
import { enrowVerifyEmailTool } from '@/tools/enrow/verify_email'
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
const JOB_ID = 'job-123'
|
||||
|
||||
/** `postProcess`'s third argument — never invoked by these polling tools. */
|
||||
const executeTool = async (): Promise<ToolResponse> => {
|
||||
throw new Error('executeTool should not be called')
|
||||
}
|
||||
|
||||
/** Verbatim documented 200 body for `GET /email/find/single`. */
|
||||
const DOCUMENTED_FIND_BODY = {
|
||||
email: 'john.doe@stripe.com',
|
||||
qualification: 'valid',
|
||||
info: {
|
||||
company_domain: 'stripe.com',
|
||||
company_name: 'Stripe',
|
||||
fullname: 'John Doe',
|
||||
firstname: 'John',
|
||||
lastname: 'Doe',
|
||||
},
|
||||
custom: {},
|
||||
}
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
} as Response
|
||||
}
|
||||
|
||||
const findParams: EnrowFindEmailParams = {
|
||||
apiKey: 'test-key',
|
||||
fullname: 'John Doe',
|
||||
company_domain: 'stripe.com',
|
||||
}
|
||||
|
||||
const submittedFindResult: EnrowFindEmailResponse = {
|
||||
success: true,
|
||||
output: {
|
||||
id: JOB_ID,
|
||||
email: null,
|
||||
qualification: null,
|
||||
fullname: null,
|
||||
firstname: null,
|
||||
lastname: null,
|
||||
company_name: null,
|
||||
company_domain: null,
|
||||
},
|
||||
}
|
||||
|
||||
/** `MAX_POLL_TIME_MS / POLL_INTERVAL_MS` in `find_email.ts` — 120_000 / 3_000. */
|
||||
const MAX_POLLS = 40
|
||||
|
||||
describe('enrow_find_email', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('maps the documented nested `info` payload onto the flat output', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, DOCUMENTED_FIND_BODY))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const result = await enrowFindEmailTool.postProcess!(
|
||||
submittedFindResult,
|
||||
findParams,
|
||||
executeTool
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output).toEqual({
|
||||
id: JOB_ID,
|
||||
email: 'john.doe@stripe.com',
|
||||
qualification: 'valid',
|
||||
fullname: 'John Doe',
|
||||
firstname: 'John',
|
||||
lastname: 'Doe',
|
||||
company_name: 'Stripe',
|
||||
company_domain: 'stripe.com',
|
||||
})
|
||||
expect('linkedin_url' in result.output).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps polling on a 202 in-progress body', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(202, { qualification: 'ongoing' }))
|
||||
.mockResolvedValueOnce(jsonResponse(200, DOCUMENTED_FIND_BODY))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const result = await enrowFindEmailTool.postProcess!(
|
||||
submittedFindResult,
|
||||
findParams,
|
||||
executeTool
|
||||
)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(result.output.email).toBe('john.doe@stripe.com')
|
||||
expect(result.output.firstname).toBe('John')
|
||||
})
|
||||
|
||||
it('throws with the status and body when a poll returns a non-2xx status', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(202, { qualification: 'ongoing' }))
|
||||
.mockResolvedValueOnce(jsonResponse(401, { message: 'invalid api key' }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(
|
||||
enrowFindEmailTool.postProcess!(submittedFindResult, findParams, executeTool)
|
||||
).rejects.toThrow(/poll error: 401 - .*invalid api key/)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('gives up after the polling window when every poll stays 202', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(202, { qualification: 'ongoing' }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(
|
||||
enrowFindEmailTool.postProcess!(submittedFindResult, findParams, executeTool)
|
||||
).rejects.toThrow('Enrow find-email did not complete within the polling window')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(MAX_POLLS)
|
||||
})
|
||||
})
|
||||
|
||||
describe('enrow_verify_email', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('reads the FLAT documented verify body — there is no `info` level here', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse(200, { email: 'john.doe@stripe.com', qualification: 'valid' })
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const submitted: EnrowVerifyEmailResponse = {
|
||||
success: true,
|
||||
output: { id: JOB_ID, email: null, qualification: null },
|
||||
}
|
||||
const params: EnrowVerifyEmailParams = { apiKey: 'test-key', email: 'john.doe@stripe.com' }
|
||||
|
||||
const result = await enrowVerifyEmailTool.postProcess!(submitted, params, executeTool)
|
||||
|
||||
expect(result.output).toEqual({
|
||||
id: JOB_ID,
|
||||
email: 'john.doe@stripe.com',
|
||||
qualification: 'valid',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -15,16 +15,23 @@ import type { ToolConfig } from '@/tools/types'
|
||||
const POLL_INTERVAL_MS = 3000
|
||||
const MAX_POLL_TIME_MS = 120_000
|
||||
|
||||
/** Map a raw Enrow find-email result payload to the typed output shape. */
|
||||
function mapFindResult(data: Record<string, unknown>): EnrowFindEmailResult {
|
||||
/**
|
||||
* Map a raw Enrow find-email result payload to the typed output shape.
|
||||
*
|
||||
* `email` and `qualification` are top-level on the 200 body; the person and
|
||||
* company fields are nested under `info`.
|
||||
*/
|
||||
function mapFindResult(data: Record<string, unknown>, jobId: string): EnrowFindEmailResult {
|
||||
const info = (data.info as Record<string, unknown> | undefined) ?? {}
|
||||
return {
|
||||
id: (data.id as string) ?? '',
|
||||
id: jobId,
|
||||
email: (data.email as string) ?? null,
|
||||
qualification: (data.qualification as string) ?? null,
|
||||
fullname: (data.fullname as string) ?? null,
|
||||
company_name: (data.company_name as string) ?? null,
|
||||
company_domain: (data.company_domain as string) ?? null,
|
||||
linkedin_url: (data.linkedin_url as string) ?? null,
|
||||
fullname: (info.fullname as string) ?? null,
|
||||
firstname: (info.firstname as string) ?? null,
|
||||
lastname: (info.lastname as string) ?? null,
|
||||
company_name: (info.company_name as string) ?? null,
|
||||
company_domain: (info.company_domain as string) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,13 +44,13 @@ function mapFindResult(data: Record<string, unknown>): EnrowFindEmailResult {
|
||||
* search is still in progress.
|
||||
*
|
||||
* Pricing: 1 credit per valid email found (charged only on success).
|
||||
* Docs: https://enrow.readme.io/reference/find-single-email
|
||||
* Docs: https://docs.enrow.io/api-reference/email-finder/find-single
|
||||
*/
|
||||
export const enrowFindEmailTool: ToolConfig<EnrowFindEmailParams, EnrowFindEmailResponse> = {
|
||||
id: 'enrow_find_email',
|
||||
name: 'Enrow Find Email',
|
||||
description:
|
||||
'Find a verified B2B email address from a full name and company domain or name. Uses the Enrow async finder — submits a search and polls until the result is ready. Costs 1 credit per valid email found. (https://enrow.readme.io/reference/find-single-email)',
|
||||
'Find a verified B2B email address from a full name and company domain or name. Uses the Enrow async finder — submits a search and polls until the result is ready. Costs 1 credit per valid email found. (https://docs.enrow.io/api-reference/email-finder/find-single)',
|
||||
version: '1.0.0',
|
||||
|
||||
hosting: enrowHosting<EnrowFindEmailParams>((_params, output) => {
|
||||
@@ -111,9 +118,10 @@ export const enrowFindEmailTool: ToolConfig<EnrowFindEmailParams, EnrowFindEmail
|
||||
email: null,
|
||||
qualification: null,
|
||||
fullname: null,
|
||||
firstname: null,
|
||||
lastname: null,
|
||||
company_name: null,
|
||||
company_domain: null,
|
||||
linkedin_url: null,
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -158,7 +166,7 @@ export const enrowFindEmailTool: ToolConfig<EnrowFindEmailParams, EnrowFindEmail
|
||||
const data = (json as Record<string, unknown>) ?? {}
|
||||
return {
|
||||
success: true,
|
||||
output: mapFindResult({ ...data, id: jobId }),
|
||||
output: mapFindResult(data, jobId),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +182,16 @@ export const enrowFindEmailTool: ToolConfig<EnrowFindEmailParams, EnrowFindEmail
|
||||
description: 'Full name of the person searched',
|
||||
optional: true,
|
||||
},
|
||||
firstname: {
|
||||
type: 'string',
|
||||
description: 'First name of the person searched',
|
||||
optional: true,
|
||||
},
|
||||
lastname: {
|
||||
type: 'string',
|
||||
description: 'Last name of the person searched',
|
||||
optional: true,
|
||||
},
|
||||
company_name: {
|
||||
type: 'string',
|
||||
description: 'Company name associated with the result',
|
||||
@@ -184,10 +202,5 @@ export const enrowFindEmailTool: ToolConfig<EnrowFindEmailParams, EnrowFindEmail
|
||||
description: 'Company domain associated with the result',
|
||||
optional: true,
|
||||
},
|
||||
linkedin_url: {
|
||||
type: 'string',
|
||||
description: 'LinkedIn profile URL of the person',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -20,9 +20,10 @@ export interface EnrowFindEmailResult {
|
||||
/** Enrow quality qualifier: "valid" | "invalid" | null (if not yet finished). */
|
||||
qualification: string | null
|
||||
fullname: string | null
|
||||
firstname: string | null
|
||||
lastname: string | null
|
||||
company_name: string | null
|
||||
company_domain: string | null
|
||||
linkedin_url: string | null
|
||||
}
|
||||
|
||||
export interface EnrowFindEmailResponse extends ToolResponse {
|
||||
|
||||
@@ -33,13 +33,13 @@ function mapVerifyResult(data: Record<string, unknown>, jobId: string): EnrowVer
|
||||
* (complete) or the polling window expires. HTTP 202 means still in progress.
|
||||
*
|
||||
* Pricing: 0.25 credits per verification (charged per call).
|
||||
* Docs: https://enrow.readme.io/reference/verify-single-email
|
||||
* Docs: https://docs.enrow.io/api-reference/email-verifier/verify-single
|
||||
*/
|
||||
export const enrowVerifyEmailTool: ToolConfig<EnrowVerifyEmailParams, EnrowVerifyEmailResponse> = {
|
||||
id: 'enrow_verify_email',
|
||||
name: 'Enrow Verify Email',
|
||||
description:
|
||||
'Verify the deliverability of an email address using the Enrow async verifier. Submits a verification request and polls until the result is ready. Costs 0.25 credits per verification. (https://enrow.readme.io/reference/verify-single-email)',
|
||||
'Verify the deliverability of an email address using the Enrow async verifier. Submits a verification request and polls until the result is ready. Costs 0.25 credits per verification. (https://docs.enrow.io/api-reference/email-verifier/verify-single)',
|
||||
version: '1.0.0',
|
||||
|
||||
hosting: enrowHosting<EnrowVerifyEmailParams>((_params, output) => {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -4,6 +4,7 @@ import type {
|
||||
LangsmithCreateFeedbackParams,
|
||||
LangsmithCreateFeedbackResponse,
|
||||
} from '@/tools/langsmith/types'
|
||||
import { LANGSMITH_API_BASE, truncateLangsmithErrorText } from '@/tools/langsmith/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const langsmithCreateFeedbackTool: ToolConfig<
|
||||
@@ -65,7 +66,7 @@ export const langsmithCreateFeedbackTool: ToolConfig<
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: () => 'https://api.smith.langchain.com/feedback',
|
||||
url: () => `${LANGSMITH_API_BASE}/feedback`,
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
'X-Api-Key': params.apiKey,
|
||||
@@ -91,7 +92,9 @@ export const langsmithCreateFeedbackTool: ToolConfig<
|
||||
transformResponse: async (response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`LangSmith create feedback failed (${response.status}): ${errorText}`)
|
||||
throw new Error(
|
||||
`LangSmith create feedback failed (${response.status}): ${truncateLangsmithErrorText(errorText)}`
|
||||
)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { filterUndefined } from '@sim/utils/object'
|
||||
import type { LangsmithCreateRunParams, LangsmithCreateRunResponse } from '@/tools/langsmith/types'
|
||||
import { normalizeLangsmithRunPayload } from '@/tools/langsmith/utils'
|
||||
import {
|
||||
LANGSMITH_API_BASE,
|
||||
normalizeLangsmithRunPayload,
|
||||
truncateLangsmithErrorText,
|
||||
} from '@/tools/langsmith/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const langsmithCreateRunTool: ToolConfig<
|
||||
@@ -122,7 +126,7 @@ export const langsmithCreateRunTool: ToolConfig<
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: () => 'https://api.smith.langchain.com/runs',
|
||||
url: () => `${LANGSMITH_API_BASE}/runs`,
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
'X-Api-Key': params.apiKey,
|
||||
@@ -133,19 +137,20 @@ export const langsmithCreateRunTool: ToolConfig<
|
||||
const normalizedPayload: Record<string, unknown> = {
|
||||
...payload,
|
||||
name: payload.name?.trim(),
|
||||
inputs: params.inputs,
|
||||
outputs: params.run_outputs,
|
||||
extra: params.extra,
|
||||
tags: params.tags,
|
||||
status: params.status,
|
||||
error: params.error,
|
||||
events: params.events,
|
||||
}
|
||||
|
||||
return filterUndefined(normalizedPayload)
|
||||
},
|
||||
},
|
||||
transformResponse: async (response, params) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(
|
||||
`LangSmith create run failed (${response.status}): ${truncateLangsmithErrorText(errorText)}`
|
||||
)
|
||||
}
|
||||
|
||||
const runId = params ? normalizeLangsmithRunPayload(params).runId : null
|
||||
const data = (await response.json()) as Record<string, unknown>
|
||||
const directMessage =
|
||||
|
||||
@@ -4,7 +4,12 @@ import type {
|
||||
LangsmithCreateRunsBatchResponse,
|
||||
LangsmithRunPayload,
|
||||
} from '@/tools/langsmith/types'
|
||||
import { normalizeLangsmithRunPayload } from '@/tools/langsmith/utils'
|
||||
import {
|
||||
LANGSMITH_API_BASE,
|
||||
normalizeLangsmithRunPayload,
|
||||
prepareLangsmithPatchPayload,
|
||||
truncateLangsmithErrorText,
|
||||
} from '@/tools/langsmith/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const langsmithCreateRunsBatchTool: ToolConfig<
|
||||
@@ -36,7 +41,7 @@ export const langsmithCreateRunsBatchTool: ToolConfig<
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: () => 'https://api.smith.langchain.com/runs/batch',
|
||||
url: () => `${LANGSMITH_API_BASE}/runs/batch`,
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
'X-Api-Key': params.apiKey,
|
||||
@@ -48,7 +53,7 @@ export const langsmithCreateRunsBatchTool: ToolConfig<
|
||||
? params.post.map((run) => normalizeLangsmithRunPayload(run).payload)
|
||||
: undefined,
|
||||
patch: params.patch
|
||||
? params.patch.map((run) => normalizeLangsmithRunPayload(run).payload)
|
||||
? params.patch.map((run) => prepareLangsmithPatchPayload(run).payload)
|
||||
: undefined,
|
||||
}
|
||||
|
||||
@@ -56,6 +61,13 @@ export const langsmithCreateRunsBatchTool: ToolConfig<
|
||||
},
|
||||
},
|
||||
transformResponse: async (response, params) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(
|
||||
`LangSmith create runs batch failed (${response.status}): ${truncateLangsmithErrorText(errorText)}`
|
||||
)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>
|
||||
const directMessage =
|
||||
typeof (data as { message?: unknown }).message === 'string'
|
||||
@@ -71,14 +83,19 @@ export const langsmithCreateRunsBatchTool: ToolConfig<
|
||||
})
|
||||
.filter((value): value is string => Boolean(value))
|
||||
|
||||
const collectRunIds = (runs?: LangsmithRunPayload[]) =>
|
||||
runs?.map((run) => normalizeLangsmithRunPayload(run).runId) ?? []
|
||||
const collectRunIds = (
|
||||
runs: LangsmithRunPayload[] | undefined,
|
||||
resolve: (run: LangsmithRunPayload) => string
|
||||
) => runs?.map(resolve) ?? []
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
accepted: true,
|
||||
runIds: [...collectRunIds(params?.post), ...collectRunIds(params?.patch)],
|
||||
runIds: [
|
||||
...collectRunIds(params?.post, (run) => normalizeLangsmithRunPayload(run).runId),
|
||||
...collectRunIds(params?.patch, (run) => prepareLangsmithPatchPayload(run).runId),
|
||||
],
|
||||
message: directMessage ?? null,
|
||||
messages: messages.length ? messages : undefined,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LangsmithGetRunParams, LangsmithGetRunResponse } from '@/tools/langsmith/types'
|
||||
import { LANGSMITH_API_BASE, truncateLangsmithErrorText } from '@/tools/langsmith/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const langsmithGetRunTool: ToolConfig<LangsmithGetRunParams, LangsmithGetRunResponse> = {
|
||||
@@ -21,7 +22,7 @@ export const langsmithGetRunTool: ToolConfig<LangsmithGetRunParams, LangsmithGet
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: (params) => `https://api.smith.langchain.com/runs/${params.runId.trim()}`,
|
||||
url: (params) => `${LANGSMITH_API_BASE}/runs/${encodeURIComponent(params.runId.trim())}`,
|
||||
method: 'GET',
|
||||
headers: (params) => ({
|
||||
'X-Api-Key': params.apiKey,
|
||||
@@ -30,7 +31,9 @@ export const langsmithGetRunTool: ToolConfig<LangsmithGetRunParams, LangsmithGet
|
||||
transformResponse: async (response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`LangSmith get run failed (${response.status}): ${errorText}`)
|
||||
throw new Error(
|
||||
`LangSmith get run failed (${response.status}): ${truncateLangsmithErrorText(errorText)}`
|
||||
)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { langsmithCreateFeedbackTool } from '@/tools/langsmith/create_feedback'
|
||||
import { langsmithCreateRunTool } from '@/tools/langsmith/create_run'
|
||||
import { langsmithCreateRunsBatchTool } from '@/tools/langsmith/create_runs_batch'
|
||||
import { langsmithGetRunTool } from '@/tools/langsmith/get_run'
|
||||
import type {
|
||||
LangsmithCreateFeedbackParams,
|
||||
LangsmithCreateRunParams,
|
||||
LangsmithCreateRunsBatchParams,
|
||||
LangsmithGetRunParams,
|
||||
LangsmithUpdateRunParams,
|
||||
} from '@/tools/langsmith/types'
|
||||
import { langsmithUpdateRunTool } from '@/tools/langsmith/update_run'
|
||||
import { ERROR_TEXT_MAX_LENGTH } from '@/tools/langsmith/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
const resolveUrl = <P>(tool: ToolConfig<P, never>, params: P): string =>
|
||||
typeof tool.request.url === 'string' ? tool.request.url : tool.request.url(params)
|
||||
|
||||
const createRunParams: LangsmithCreateRunParams = {
|
||||
apiKey: 'test-key',
|
||||
id: 'run-1',
|
||||
name: 'my run',
|
||||
run_type: 'chain',
|
||||
}
|
||||
|
||||
const createRunsBatchParams: LangsmithCreateRunsBatchParams = {
|
||||
apiKey: 'test-key',
|
||||
post: [{ id: 'run-1', name: 'my run', run_type: 'chain' }],
|
||||
}
|
||||
|
||||
const createFeedbackParams: LangsmithCreateFeedbackParams = {
|
||||
apiKey: 'test-key',
|
||||
runId: 'run-1',
|
||||
key: 'correctness',
|
||||
}
|
||||
|
||||
const getRunParams: LangsmithGetRunParams = { apiKey: 'test-key', runId: 'run-1' }
|
||||
|
||||
const updateRunParams: LangsmithUpdateRunParams = {
|
||||
apiKey: 'test-key',
|
||||
runId: 'run-1',
|
||||
status: 'success',
|
||||
}
|
||||
|
||||
describe('langsmith tool request URLs', () => {
|
||||
it('targets the versioned runs endpoint for create run', () => {
|
||||
expect(resolveUrl(langsmithCreateRunTool as never, createRunParams)).toBe(
|
||||
'https://api.smith.langchain.com/api/v1/runs'
|
||||
)
|
||||
})
|
||||
|
||||
it('targets the versioned batch endpoint for create runs batch', () => {
|
||||
expect(resolveUrl(langsmithCreateRunsBatchTool as never, createRunsBatchParams)).toBe(
|
||||
'https://api.smith.langchain.com/api/v1/runs/batch'
|
||||
)
|
||||
})
|
||||
|
||||
it('targets the versioned feedback endpoint for create feedback', () => {
|
||||
expect(resolveUrl(langsmithCreateFeedbackTool as never, createFeedbackParams)).toBe(
|
||||
'https://api.smith.langchain.com/api/v1/feedback'
|
||||
)
|
||||
})
|
||||
|
||||
it('targets the versioned run endpoint for get run', () => {
|
||||
expect(resolveUrl(langsmithGetRunTool as never, getRunParams)).toBe(
|
||||
'https://api.smith.langchain.com/api/v1/runs/run-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('targets the versioned run endpoint for update run', () => {
|
||||
expect(resolveUrl(langsmithUpdateRunTool as never, updateRunParams)).toBe(
|
||||
'https://api.smith.langchain.com/api/v1/runs/run-1'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const forbidden = () => new Response('{"detail":"Forbidden"}', { status: 403 })
|
||||
|
||||
describe('langsmith transformResponse error handling', () => {
|
||||
it('rejects a 403 from create run', async () => {
|
||||
await expect(
|
||||
langsmithCreateRunTool.transformResponse!(forbidden(), createRunParams)
|
||||
).rejects.toThrow(/403/)
|
||||
})
|
||||
|
||||
it('rejects a 403 from create runs batch', async () => {
|
||||
await expect(
|
||||
langsmithCreateRunsBatchTool.transformResponse!(forbidden(), createRunsBatchParams)
|
||||
).rejects.toThrow(/403/)
|
||||
})
|
||||
|
||||
it('rejects a 403 from create feedback', async () => {
|
||||
await expect(
|
||||
langsmithCreateFeedbackTool.transformResponse!(forbidden(), createFeedbackParams)
|
||||
).rejects.toThrow(/403/)
|
||||
})
|
||||
|
||||
it('rejects a 403 from get run', async () => {
|
||||
await expect(langsmithGetRunTool.transformResponse!(forbidden(), getRunParams)).rejects.toThrow(
|
||||
/403/
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a 403 from update run', async () => {
|
||||
await expect(
|
||||
langsmithUpdateRunTool.transformResponse!(forbidden(), updateRunParams)
|
||||
).rejects.toThrow(/403/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('langsmith run id path traversal', () => {
|
||||
const traversingRunId = '../sessions/00000000-0000-4000-8000-000000000000'
|
||||
|
||||
it('keeps a traversing get-run id inside /api/v1/runs/', () => {
|
||||
const url = new URL(
|
||||
resolveUrl(langsmithGetRunTool as never, {
|
||||
apiKey: 'test-key',
|
||||
runId: traversingRunId,
|
||||
} satisfies LangsmithGetRunParams)
|
||||
)
|
||||
|
||||
expect(url.pathname.startsWith('/api/v1/runs/')).toBe(true)
|
||||
expect(url.pathname).toBe('/api/v1/runs/..%2Fsessions%2F00000000-0000-4000-8000-000000000000')
|
||||
})
|
||||
|
||||
it('keeps a traversing update-run id inside /api/v1/runs/', () => {
|
||||
const url = new URL(
|
||||
resolveUrl(langsmithUpdateRunTool as never, {
|
||||
apiKey: 'test-key',
|
||||
runId: traversingRunId,
|
||||
status: 'success',
|
||||
} satisfies LangsmithUpdateRunParams)
|
||||
)
|
||||
|
||||
expect(url.pathname.startsWith('/api/v1/runs/')).toBe(true)
|
||||
expect(url.pathname).toBe('/api/v1/runs/..%2Fsessions%2F00000000-0000-4000-8000-000000000000')
|
||||
})
|
||||
|
||||
it('leaves a legitimate uuid run id unchanged', () => {
|
||||
const runId = '3f0c7f4e-9d3a-4f2b-8f6a-1d2c3b4a5e6f'
|
||||
const url = new URL(
|
||||
resolveUrl(langsmithGetRunTool as never, {
|
||||
apiKey: 'test-key',
|
||||
runId,
|
||||
} satisfies LangsmithGetRunParams)
|
||||
)
|
||||
|
||||
expect(url.pathname).toBe(`/api/v1/runs/${runId}`)
|
||||
})
|
||||
})
|
||||
|
||||
const jsonOk = (body: unknown) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
describe('langsmith transformResponse success handling', () => {
|
||||
it('maps a 200 get run response', async () => {
|
||||
const result = await langsmithGetRunTool.transformResponse!(
|
||||
jsonOk({
|
||||
id: 'run-1',
|
||||
name: 'my run',
|
||||
run_type: 'chain',
|
||||
status: 'success',
|
||||
start_time: '2026-01-01T00:00:00Z',
|
||||
end_time: '2026-01-01T00:00:05Z',
|
||||
inputs: { question: 'hi' },
|
||||
outputs: { answer: 'hello' },
|
||||
tags: ['prod'],
|
||||
session_id: 'session-1',
|
||||
trace_id: 'trace-1',
|
||||
total_tokens: 42,
|
||||
total_cost: '0.01',
|
||||
}),
|
||||
getRunParams
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output).toMatchObject({
|
||||
id: 'run-1',
|
||||
runId: 'run-1',
|
||||
name: 'my run',
|
||||
runType: 'chain',
|
||||
status: 'success',
|
||||
inputs: { question: 'hi' },
|
||||
outputs: { answer: 'hello' },
|
||||
tags: ['prod'],
|
||||
sessionId: 'session-1',
|
||||
traceId: 'trace-1',
|
||||
totalTokens: 42,
|
||||
totalCost: '0.01',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps a 200 create run response', async () => {
|
||||
const result = await langsmithCreateRunTool.transformResponse!(
|
||||
jsonOk({ message: 'Runs accepted' }),
|
||||
createRunParams
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output).toEqual({
|
||||
accepted: true,
|
||||
runId: 'run-1',
|
||||
message: 'Runs accepted',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps a 200 create runs batch response', async () => {
|
||||
const result = await langsmithCreateRunsBatchTool.transformResponse!(
|
||||
jsonOk({ message: 'Runs accepted' }),
|
||||
createRunsBatchParams
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output).toMatchObject({
|
||||
accepted: true,
|
||||
runIds: ['run-1'],
|
||||
message: 'Runs accepted',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps a 200 create feedback response', async () => {
|
||||
const result = await langsmithCreateFeedbackTool.transformResponse!(
|
||||
jsonOk({
|
||||
id: 'feedback-1',
|
||||
key: 'correctness',
|
||||
run_id: 'run-1',
|
||||
score: 1,
|
||||
value: 'good',
|
||||
comment: 'looks right',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
}),
|
||||
createFeedbackParams
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output).toEqual({
|
||||
id: 'feedback-1',
|
||||
key: 'correctness',
|
||||
runId: 'run-1',
|
||||
score: 1,
|
||||
value: 'good',
|
||||
comment: 'looks right',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps a 200 update run response', async () => {
|
||||
const result = await langsmithUpdateRunTool.transformResponse!(
|
||||
jsonOk({ message: 'Run updated' }),
|
||||
updateRunParams
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output).toEqual({
|
||||
accepted: true,
|
||||
runId: 'run-1',
|
||||
message: 'Run updated',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('langsmith error body truncation', () => {
|
||||
const hugeBody = 'x'.repeat(5000)
|
||||
|
||||
const cases: Array<[string, () => Promise<unknown>]> = [
|
||||
[
|
||||
'create run',
|
||||
() =>
|
||||
langsmithCreateRunTool.transformResponse!(oversized(), createRunParams) as Promise<never>,
|
||||
],
|
||||
[
|
||||
'create runs batch',
|
||||
() =>
|
||||
langsmithCreateRunsBatchTool.transformResponse!(
|
||||
oversized(),
|
||||
createRunsBatchParams
|
||||
) as Promise<never>,
|
||||
],
|
||||
[
|
||||
'create feedback',
|
||||
() =>
|
||||
langsmithCreateFeedbackTool.transformResponse!(
|
||||
oversized(),
|
||||
createFeedbackParams
|
||||
) as Promise<never>,
|
||||
],
|
||||
[
|
||||
'get run',
|
||||
() => langsmithGetRunTool.transformResponse!(oversized(), getRunParams) as Promise<never>,
|
||||
],
|
||||
[
|
||||
'update run',
|
||||
() =>
|
||||
langsmithUpdateRunTool.transformResponse!(oversized(), updateRunParams) as Promise<never>,
|
||||
],
|
||||
]
|
||||
|
||||
function oversized() {
|
||||
return new Response(hugeBody, { status: 500 })
|
||||
}
|
||||
|
||||
it.each(cases)('caps the echoed upstream error body for %s', async (_name, run) => {
|
||||
const error = await run().then(
|
||||
() => null,
|
||||
(thrown: unknown) => toError(thrown)
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
const echoed = error!.message.slice(error!.message.indexOf('x'))
|
||||
expect(echoed).toMatch(/^x{497}\.\.\.$/)
|
||||
expect(echoed).toHaveLength(ERROR_TEXT_MAX_LENGTH)
|
||||
})
|
||||
})
|
||||
|
||||
const resolveBody = <P>(tool: ToolConfig<P, never>, params: P): Record<string, unknown> =>
|
||||
tool.request.body!(params) as Record<string, unknown>
|
||||
|
||||
describe('langsmith credential containment', () => {
|
||||
const secret = 'lsv2_pt_super_secret_key'
|
||||
|
||||
it('keeps the api key out of the create run body', () => {
|
||||
const body = resolveBody(langsmithCreateRunTool as never, {
|
||||
...createRunParams,
|
||||
apiKey: secret,
|
||||
run_outputs: { answer: 'hello' },
|
||||
} satisfies LangsmithCreateRunParams)
|
||||
|
||||
expect(JSON.stringify(body)).not.toContain(secret)
|
||||
expect(JSON.stringify(body)).not.toContain('apiKey')
|
||||
expect(body.outputs).toEqual({ answer: 'hello' })
|
||||
expect(body).not.toHaveProperty('run_outputs')
|
||||
})
|
||||
|
||||
it('keeps the api key out of the create runs batch body', () => {
|
||||
const body = resolveBody(langsmithCreateRunsBatchTool as never, {
|
||||
apiKey: secret,
|
||||
post: [{ id: 'run-1', name: 'my run', run_type: 'chain' }],
|
||||
patch: [
|
||||
{ id: 'run-0', name: 'older run', run_type: 'chain', end_time: '2026-01-01T00:00:05Z' },
|
||||
],
|
||||
} satisfies LangsmithCreateRunsBatchParams)
|
||||
|
||||
expect(JSON.stringify(body)).not.toContain(secret)
|
||||
expect(JSON.stringify(body)).not.toContain('apiKey')
|
||||
})
|
||||
|
||||
it('drops unknown caller-supplied fields rather than forwarding them', () => {
|
||||
const body = resolveBody(
|
||||
langsmithCreateRunTool as never,
|
||||
{
|
||||
...createRunParams,
|
||||
apiKey: secret,
|
||||
futureCredential: secret,
|
||||
} as unknown as LangsmithCreateRunParams
|
||||
)
|
||||
|
||||
expect(JSON.stringify(body)).not.toContain(secret)
|
||||
expect(body).not.toHaveProperty('futureCredential')
|
||||
})
|
||||
})
|
||||
|
||||
describe('langsmith reported run id matches the sent run id', () => {
|
||||
it('reports the generated id that was actually sent for create run', async () => {
|
||||
const params: LangsmithCreateRunParams = {
|
||||
apiKey: 'test-key',
|
||||
name: 'my run',
|
||||
run_type: 'chain',
|
||||
}
|
||||
|
||||
const body = resolveBody(langsmithCreateRunTool as never, params)
|
||||
const sentId = body.id as string
|
||||
|
||||
expect(sentId).toEqual(expect.any(String))
|
||||
|
||||
const result = await langsmithCreateRunTool.transformResponse!(
|
||||
jsonOk({ [sentId]: { message: 'Run accepted' } }),
|
||||
params
|
||||
)
|
||||
|
||||
expect(result.output.runId).toBe(sentId)
|
||||
expect(result.output.message).toBe('Run accepted')
|
||||
})
|
||||
|
||||
it('reports the generated ids that were actually sent for create runs batch', async () => {
|
||||
const params: LangsmithCreateRunsBatchParams = {
|
||||
apiKey: 'test-key',
|
||||
post: [
|
||||
{ name: 'first', run_type: 'chain' },
|
||||
{ name: 'second', run_type: 'llm' },
|
||||
],
|
||||
}
|
||||
|
||||
const body = resolveBody(langsmithCreateRunsBatchTool as never, params)
|
||||
const sentIds = (body.post as Array<Record<string, unknown>>).map((run) => run.id)
|
||||
|
||||
const result = await langsmithCreateRunsBatchTool.transformResponse!(
|
||||
jsonOk({ message: 'Runs accepted' }),
|
||||
params
|
||||
)
|
||||
|
||||
expect(result.output.runIds).toEqual(sentIds)
|
||||
})
|
||||
})
|
||||
|
||||
describe('langsmith batch patch entries', () => {
|
||||
it('passes patch entries through without fabricating identity or trace fields', () => {
|
||||
const body = resolveBody(langsmithCreateRunsBatchTool as never, {
|
||||
apiKey: 'test-key',
|
||||
patch: [{ id: 'run-1', name: 'my run', run_type: 'chain', end_time: '2026-01-01T00:00:05Z' }],
|
||||
} satisfies LangsmithCreateRunsBatchParams)
|
||||
|
||||
expect(body.patch).toEqual([
|
||||
{ id: 'run-1', name: 'my run', run_type: 'chain', end_time: '2026-01-01T00:00:05Z' },
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a patch entry that carries no run id', () => {
|
||||
expect(() =>
|
||||
resolveBody(langsmithCreateRunsBatchTool as never, {
|
||||
apiKey: 'test-key',
|
||||
patch: [{ name: 'my run', run_type: 'chain', end_time: '2026-01-01T00:00:05Z' }],
|
||||
} satisfies LangsmithCreateRunsBatchParams)
|
||||
).toThrow(/patch entries must carry the id of an existing run/i)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { filterUndefined } from '@sim/utils/object'
|
||||
import type { LangsmithUpdateRunParams, LangsmithUpdateRunResponse } from '@/tools/langsmith/types'
|
||||
import { LANGSMITH_API_BASE, truncateLangsmithErrorText } from '@/tools/langsmith/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const langsmithUpdateRunTool: ToolConfig<
|
||||
@@ -73,7 +74,7 @@ export const langsmithUpdateRunTool: ToolConfig<
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: (params) => `https://api.smith.langchain.com/runs/${params.runId.trim()}`,
|
||||
url: (params) => `${LANGSMITH_API_BASE}/runs/${encodeURIComponent(params.runId.trim())}`,
|
||||
method: 'PATCH',
|
||||
headers: (params) => ({
|
||||
'X-Api-Key': params.apiKey,
|
||||
@@ -104,7 +105,9 @@ export const langsmithUpdateRunTool: ToolConfig<
|
||||
transformResponse: async (response, params) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`LangSmith update run failed (${response.status}): ${errorText}`)
|
||||
throw new Error(
|
||||
`LangSmith update run failed (${response.status}): ${truncateLangsmithErrorText(errorText)}`
|
||||
)
|
||||
}
|
||||
|
||||
const responseText = await response.text()
|
||||
|
||||
@@ -1,11 +1,68 @@
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { truncate } from '@sim/utils/string'
|
||||
import type { LangsmithRunPayload } from '@/tools/langsmith/types'
|
||||
|
||||
/** Versioned LangSmith API base. Only `/api/v1/*` paths are declared in the published OpenAPI spec. */
|
||||
export const LANGSMITH_API_BASE = 'https://api.smith.langchain.com/api/v1'
|
||||
|
||||
/** Ellipsis appended to an upstream error body that had to be cut short. */
|
||||
const ERROR_TEXT_SUFFIX = '...'
|
||||
|
||||
/**
|
||||
* Upper bound on the total number of characters an upstream error body may
|
||||
* contribute to a thrown message, ellipsis included. An HTML error page or a
|
||||
* large payload echo would otherwise land whole in the error string, the
|
||||
* execution log, and the model's context.
|
||||
*/
|
||||
export const ERROR_TEXT_MAX_LENGTH = 500
|
||||
|
||||
/**
|
||||
* Caps an upstream error body at {@link ERROR_TEXT_MAX_LENGTH} characters in
|
||||
* total. `truncate` appends its suffix *after* slicing, so the slice length is
|
||||
* reduced by the suffix length to keep the advertised bound real.
|
||||
*/
|
||||
export const truncateLangsmithErrorText = (errorText: string): string =>
|
||||
truncate(errorText, ERROR_TEXT_MAX_LENGTH - ERROR_TEXT_SUFFIX.length, ERROR_TEXT_SUFFIX)
|
||||
|
||||
/**
|
||||
* Every field the LangSmith run-ingest schema accepts. Request bodies are built
|
||||
* by selecting these keys explicitly rather than by spreading the caller's
|
||||
* params, so a credential (or any future secret-bearing param) can never reach
|
||||
* the wire: a field that is not listed here is not sent, by construction.
|
||||
*/
|
||||
const RUN_PAYLOAD_FIELDS = [
|
||||
'id',
|
||||
'name',
|
||||
'run_type',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'inputs',
|
||||
'outputs',
|
||||
'extra',
|
||||
'tags',
|
||||
'parent_run_id',
|
||||
'trace_id',
|
||||
'session_id',
|
||||
'session_name',
|
||||
'status',
|
||||
'error',
|
||||
'dotted_order',
|
||||
'events',
|
||||
] as const satisfies readonly (keyof LangsmithRunPayload)[]
|
||||
|
||||
interface NormalizedRunPayload {
|
||||
payload: LangsmithRunPayload
|
||||
runId: string
|
||||
}
|
||||
|
||||
const pickRunPayloadFields = (run: LangsmithRunPayload): LangsmithRunPayload => {
|
||||
const picked: Partial<LangsmithRunPayload> = {}
|
||||
for (const field of RUN_PAYLOAD_FIELDS) {
|
||||
if (run[field] !== undefined) Object.assign(picked, { [field]: run[field] })
|
||||
}
|
||||
return picked as LangsmithRunPayload
|
||||
}
|
||||
|
||||
const toCompactTimestamp = (startTime?: string): string => {
|
||||
const parsed = startTime ? new Date(startTime) : new Date()
|
||||
const date = Number.isNaN(parsed.getTime()) ? new Date() : parsed
|
||||
@@ -20,20 +77,70 @@ const toCompactTimestamp = (startTime?: string): string => {
|
||||
return `${year}${month}${day}T${hours}${minutes}${seconds}${micros}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoizes normalization on the caller's own params object.
|
||||
*
|
||||
* `request.body()` and `transformResponse` are separate closures that each
|
||||
* normalize the same run, and normalization mints a random id when the caller
|
||||
* supplied none. Without this cache the id reported to the workflow is not the
|
||||
* id that was sent, so every downstream `langsmith_update_run` /
|
||||
* `langsmith_create_feedback` wired to it targets a run that does not exist.
|
||||
*
|
||||
* Keying on the object identity — rather than on module-level mutable state —
|
||||
* keeps concurrent executions independent: each has its own params object, and
|
||||
* entries are collected with it.
|
||||
*/
|
||||
const normalizedRunCache = new WeakMap<LangsmithRunPayload, NormalizedRunPayload>()
|
||||
|
||||
/**
|
||||
* Builds the ingest body for a NEW run, filling in the identity and trace
|
||||
* fields LangSmith requires when the caller left them blank. Idempotent per
|
||||
* params object: repeated calls return the id that was actually sent.
|
||||
*/
|
||||
export const normalizeLangsmithRunPayload = (run: LangsmithRunPayload): NormalizedRunPayload => {
|
||||
const cached = normalizedRunCache.get(run)
|
||||
if (cached) return cached
|
||||
|
||||
const runId = run.id ?? generateId()
|
||||
const traceId = run.trace_id ?? runId
|
||||
const startTime = run.start_time ?? new Date().toISOString()
|
||||
const dottedOrder = run.dotted_order ?? `${toCompactTimestamp(startTime)}Z${runId}`
|
||||
|
||||
return {
|
||||
const normalized: NormalizedRunPayload = {
|
||||
runId,
|
||||
payload: {
|
||||
...run,
|
||||
...pickRunPayloadFields(run),
|
||||
id: runId,
|
||||
trace_id: traceId,
|
||||
start_time: startTime,
|
||||
dotted_order: dottedOrder,
|
||||
},
|
||||
}
|
||||
|
||||
normalizedRunCache.set(run, normalized)
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the ingest body for a PATCH of an existing run.
|
||||
*
|
||||
* Unlike {@link normalizeLangsmithRunPayload} this fabricates nothing: a patch
|
||||
* addresses a run that already exists, so minting an `id` would patch nothing
|
||||
* and force-writing `trace_id`/`dotted_order` would overwrite the run's real
|
||||
* position in its trace. LangSmith's own batch example builds patches by
|
||||
* spreading the original run and adding only `end_time`/`outputs`.
|
||||
*
|
||||
* @throws when the entry carries no `id`. A patch without an id is a caller
|
||||
* error with no correct interpretation; dropping it would silently discard the
|
||||
* caller's update and still report success.
|
||||
*/
|
||||
export const prepareLangsmithPatchPayload = (run: LangsmithRunPayload): NormalizedRunPayload => {
|
||||
const runId = run.id
|
||||
if (!runId) {
|
||||
throw new Error(
|
||||
'LangSmith patch entries must carry the id of an existing run. Provide `id` on every entry in `patch`.'
|
||||
)
|
||||
}
|
||||
|
||||
return { runId, payload: pickRunPayloadFields(run) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { type createMockLogger, loggerMock } from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { linkedInSharePostTool } from '@/tools/linkedin/share_post'
|
||||
import type { SharePostParams } from '@/tools/linkedin/types'
|
||||
|
||||
const toolLogger = loggerMock.createLogger.mock.results.at(-1)?.value as ReturnType<
|
||||
typeof createMockLogger
|
||||
>
|
||||
|
||||
const params: SharePostParams = {
|
||||
accessToken: 'token-123',
|
||||
text: 'Hello LinkedIn',
|
||||
}
|
||||
|
||||
const profileResult = {
|
||||
success: true as const,
|
||||
output: { sub: 'abc123' },
|
||||
}
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
const executeTool = vi.fn()
|
||||
|
||||
describe('linkedInSharePostTool.postProcess', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('reads the created post id from the x-restli-id header on an empty 201 body', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 201,
|
||||
headers: { 'x-restli-id': 'urn:li:share:123' },
|
||||
})
|
||||
)
|
||||
|
||||
const result = await linkedInSharePostTool.postProcess!(profileResult, params, executeTool)
|
||||
|
||||
expect(result.output.postId).toBe('urn:li:share:123')
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.error).toBeUndefined()
|
||||
})
|
||||
|
||||
it('still reports success with an undefined postId when 201 carries no x-restli-id header', async () => {
|
||||
mockFetch.mockResolvedValue(new Response(null, { status: 201 }))
|
||||
|
||||
const result = await linkedInSharePostTool.postProcess!(profileResult, params, executeTool)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.error).toBeUndefined()
|
||||
expect('postId' in result.output).toBe(true)
|
||||
expect(result.output.postId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('warns without leaking post content when 201 carries no x-restli-id header', async () => {
|
||||
mockFetch.mockResolvedValue(new Response(null, { status: 201 }))
|
||||
|
||||
await linkedInSharePostTool.postProcess!(profileResult, params, executeTool)
|
||||
|
||||
expect(toolLogger.warn).toHaveBeenCalledTimes(1)
|
||||
const serialized = JSON.stringify(toolLogger.warn.mock.calls)
|
||||
expect(serialized).toContain('x-restli-id')
|
||||
expect(serialized).not.toContain(params.text)
|
||||
expect(serialized).not.toContain(params.accessToken)
|
||||
})
|
||||
|
||||
it('does not warn when the x-restli-id header is present', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 201,
|
||||
headers: { 'x-restli-id': 'urn:li:share:123' },
|
||||
})
|
||||
)
|
||||
|
||||
await linkedInSharePostTool.postProcess!(profileResult, params, executeTool)
|
||||
|
||||
expect(toolLogger.warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces the LinkedIn error body when the response is not ok', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ message: 'Not enough permissions', status: 403 }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
|
||||
const result = await linkedInSharePostTool.postProcess!(profileResult, params, executeTool)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('Not enough permissions')
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type {
|
||||
LinkedInProfileOutput,
|
||||
@@ -7,6 +8,8 @@ import type {
|
||||
} from '@/tools/linkedin/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('LinkedInSharePost')
|
||||
|
||||
// Helper function to extract profile ID from various response formats
|
||||
const extractProfileId: ProfileIdExtractor = (output: unknown): string | null => {
|
||||
if (typeof output === 'object' && output !== null) {
|
||||
@@ -120,13 +123,18 @@ export const linkedInSharePostTool: ToolConfig<SharePostParams, SharePostRespons
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const postId = response.headers.get('x-restli-id') ?? undefined
|
||||
|
||||
if (!postId) {
|
||||
logger.warn(
|
||||
'LinkedIn returned a success status for ugcPosts without the x-restli-id header; the post was created but its id is unavailable',
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
postId: result.id,
|
||||
},
|
||||
output: { postId },
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fetchPointsTool } from '@/tools/qdrant/fetch_points'
|
||||
import { searchVectorTool } from '@/tools/qdrant/search_vector'
|
||||
import { upsertPointsTool } from '@/tools/qdrant/upsert_points'
|
||||
|
||||
/**
|
||||
* Builds a `Response` carrying a Qdrant REST envelope.
|
||||
* @see https://api.qdrant.tech/
|
||||
*/
|
||||
function qdrantResponse(body: unknown, init?: ResponseInit): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...init,
|
||||
})
|
||||
}
|
||||
|
||||
describe('qdrant_search_vector transformResponse', () => {
|
||||
it('unwraps result.points into the declared array output', async () => {
|
||||
const response = qdrantResponse({
|
||||
usage: { cpu: 1, payload_io_read: 0, payload_io_write: 0 },
|
||||
time: 0.002,
|
||||
status: 'ok',
|
||||
result: {
|
||||
points: [
|
||||
{
|
||||
id: 'point-1',
|
||||
version: 3,
|
||||
score: 0.87,
|
||||
payload: { title: 'first' },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const result = await searchVectorTool.transformResponse!(response, {} as never)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(Array.isArray(result.output.data)).toBe(true)
|
||||
expect(result.output.data).toHaveLength(1)
|
||||
expect(result.output.data[0].score).toBe(0.87)
|
||||
expect(result.output.data[0].id).toBe('point-1')
|
||||
expect(result.output.status).toBe('ok')
|
||||
})
|
||||
|
||||
it('returns an empty array when result is null', async () => {
|
||||
const response = qdrantResponse({
|
||||
time: 0.001,
|
||||
status: 'ok',
|
||||
result: null,
|
||||
})
|
||||
|
||||
const result = await searchVectorTool.transformResponse!(response, {} as never)
|
||||
|
||||
expect(result.output.data).toEqual([])
|
||||
})
|
||||
|
||||
/**
|
||||
* A bare-array `result` is the documented shape of `/points/search`, an endpoint this tool
|
||||
* never calls — the request hardcodes `/points/query`, whose `QueryResponse` declares `points`
|
||||
* as a required property. A cluster older than Qdrant v1.10 has no `/points/query` at all and
|
||||
* answers HTTP 404, which the executor throws on before `transformResponse` ever runs. So this
|
||||
* input is unreachable in production: the case below pins the mapping's behavior on an
|
||||
* impossible shape, it does not endorse silently dropping points that could actually arrive.
|
||||
*/
|
||||
it('is unreachable for /points/query: a bare-array result maps to an empty array', async () => {
|
||||
const response = qdrantResponse({
|
||||
time: 0.002,
|
||||
status: 'ok',
|
||||
result: [{ id: 'point-1', version: 3, score: 0.87, payload: { title: 'first' } }],
|
||||
})
|
||||
|
||||
const result = await searchVectorTool.transformResponse!(response, {} as never)
|
||||
|
||||
expect(result.output.data).toEqual([])
|
||||
expect(result.output.status).toBe('ok')
|
||||
})
|
||||
})
|
||||
|
||||
describe('qdrant_search_vector request', () => {
|
||||
it('targets the /points/query endpoint the result.points envelope comes from', () => {
|
||||
const url = (searchVectorTool.request!.url as (params: never) => string)({
|
||||
url: 'https://cluster.qdrant.io/',
|
||||
collection: 'my collection',
|
||||
} as never)
|
||||
|
||||
expect(url).toBe('https://cluster.qdrant.io/collections/my%20collection/points/query')
|
||||
})
|
||||
})
|
||||
|
||||
describe('qdrant_fetch_points transformResponse', () => {
|
||||
it('passes result through as the point array', async () => {
|
||||
const response = qdrantResponse({
|
||||
time: 0.003,
|
||||
status: 'ok',
|
||||
result: [
|
||||
{ id: 'point-1', payload: { title: 'first' } },
|
||||
{ id: 'point-2', payload: { title: 'second' } },
|
||||
],
|
||||
})
|
||||
|
||||
const result = await fetchPointsTool.transformResponse!(response, {} as never)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(Array.isArray(result.output.data)).toBe(true)
|
||||
expect(result.output.data).toHaveLength(2)
|
||||
expect(result.output.data[1].id).toBe('point-2')
|
||||
expect(result.output.status).toBe('ok')
|
||||
})
|
||||
})
|
||||
|
||||
describe('qdrant_upsert_points transformResponse', () => {
|
||||
it('passes result through as the operation object', async () => {
|
||||
const response = qdrantResponse({
|
||||
time: 0.004,
|
||||
status: 'ok',
|
||||
result: { operation_id: 42, status: 'completed' },
|
||||
})
|
||||
|
||||
const result = await upsertPointsTool.transformResponse!(response, {} as never)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output.data).toEqual({ operation_id: 42, status: 'completed' })
|
||||
expect(result.output.status).toBe('ok')
|
||||
})
|
||||
})
|
||||
@@ -118,7 +118,7 @@ export const searchVectorTool: ToolConfig<QdrantSearchParams, QdrantResponse> =
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
data: data.result,
|
||||
data: data.result?.points ?? [],
|
||||
status: data.status,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -53,4 +53,294 @@ describe('serper searchTool.transformResponse', () => {
|
||||
|
||||
expect(result.output.searchResults[0].date).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps videos results instead of falling through to the organic branch', async () => {
|
||||
const response = serperResponse('https://google.serper.dev/videos', {
|
||||
videos: [
|
||||
{
|
||||
title: 'Build an agent in 10 minutes',
|
||||
link: 'https://youtube.com/watch?v=abc',
|
||||
snippet: 'A walkthrough',
|
||||
source: 'YouTube',
|
||||
duration: '10:30',
|
||||
date: '3 weeks ago',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await searchTool.transformResponse!(response, {} as never)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output.searchResults).toHaveLength(1)
|
||||
expect(result.output.searchResults).toEqual([
|
||||
{
|
||||
title: 'Build an agent in 10 minutes',
|
||||
link: 'https://youtube.com/watch?v=abc',
|
||||
snippet: 'A walkthrough',
|
||||
position: 1,
|
||||
date: '3 weeks ago',
|
||||
source: 'YouTube',
|
||||
duration: '10:30',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('maps shopping results instead of falling through to the organic branch', async () => {
|
||||
const response = serperResponse('https://google.serper.dev/shopping', {
|
||||
shopping: [
|
||||
{
|
||||
title: 'Mechanical Keyboard',
|
||||
link: 'https://example.com/keyboard',
|
||||
snippet: '87-key hot-swappable',
|
||||
source: 'Example Store',
|
||||
price: '$19.99',
|
||||
imageUrl: 'https://example.com/keyboard.png',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await searchTool.transformResponse!(response, {} as never)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output.searchResults).toHaveLength(1)
|
||||
expect(result.output.searchResults).toEqual([
|
||||
{
|
||||
title: 'Mechanical Keyboard',
|
||||
link: 'https://example.com/keyboard',
|
||||
snippet: '87-key hot-swappable',
|
||||
position: 1,
|
||||
source: 'Example Store',
|
||||
price: '$19.99',
|
||||
imageUrl: 'https://example.com/keyboard.png',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('maps news results unchanged', async () => {
|
||||
const response = serperResponse('https://google.serper.dev/news', {
|
||||
news: [
|
||||
{
|
||||
title: 'Markets rally',
|
||||
link: 'https://example.com/markets',
|
||||
snippet: 'Stocks climbed',
|
||||
date: '2 hours ago',
|
||||
imageUrl: 'https://example.com/markets.png',
|
||||
source: 'Example Wire',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await searchTool.transformResponse!(response, {} as never)
|
||||
|
||||
expect(result.output.searchResults).toEqual([
|
||||
{
|
||||
title: 'Markets rally',
|
||||
link: 'https://example.com/markets',
|
||||
snippet: 'Stocks climbed',
|
||||
position: 1,
|
||||
date: '2 hours ago',
|
||||
imageUrl: 'https://example.com/markets.png',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('maps places results unchanged', async () => {
|
||||
const response = serperResponse('https://google.serper.dev/places', {
|
||||
places: [
|
||||
{
|
||||
title: 'Corner Coffee',
|
||||
link: 'https://example.com/coffee',
|
||||
snippet: 'Espresso bar',
|
||||
rating: 4.6,
|
||||
address: '1 Main St',
|
||||
ratingCount: 812,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await searchTool.transformResponse!(response, {} as never)
|
||||
|
||||
expect(result.output.searchResults).toEqual([
|
||||
{
|
||||
title: 'Corner Coffee',
|
||||
link: 'https://example.com/coffee',
|
||||
snippet: 'Espresso bar',
|
||||
position: 1,
|
||||
rating: 4.6,
|
||||
address: '1 Main St',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('maps images results unchanged', async () => {
|
||||
const response = serperResponse('https://google.serper.dev/images', {
|
||||
images: [
|
||||
{
|
||||
title: 'Sunset',
|
||||
link: 'https://example.com/sunset',
|
||||
snippet: 'Golden hour',
|
||||
imageUrl: 'https://example.com/sunset.png',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await searchTool.transformResponse!(response, {} as never)
|
||||
|
||||
expect(result.output.searchResults).toEqual([
|
||||
{
|
||||
title: 'Sunset',
|
||||
link: 'https://example.com/sunset',
|
||||
snippet: 'Golden hour',
|
||||
position: 1,
|
||||
imageUrl: 'https://example.com/sunset.png',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('numbers every item in a multi-result vertical from 1', async () => {
|
||||
const response = serperResponse('https://google.serper.dev/videos', {
|
||||
videos: [
|
||||
{ title: 'One', link: 'https://example.com/1' },
|
||||
{ title: 'Two', link: 'https://example.com/2' },
|
||||
],
|
||||
})
|
||||
|
||||
const result = await searchTool.transformResponse!(response, {} as never)
|
||||
|
||||
expect(result.output.searchResults.map((item) => item.position)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('rejects a user-supplied vertical the dispatch table cannot handle', async () => {
|
||||
const response = serperResponse('https://google.serper.dev/search', {
|
||||
organic: [{ title: 'Place', link: 'https://example.com/place' }],
|
||||
})
|
||||
|
||||
await expect(
|
||||
searchTool.transformResponse!(response, { query: 'q', apiKey: 'k', type: 'maps' } as never)
|
||||
).rejects.toThrow('Unsupported Serper search type "maps"')
|
||||
})
|
||||
|
||||
it.each(['scholar', 'patents'])(
|
||||
'maps %s results through the organic key without a fabricated date',
|
||||
async (type) => {
|
||||
const response = serperResponse(`https://google.serper.dev/${type}`, {
|
||||
organic: [
|
||||
{
|
||||
title: 'A cited work',
|
||||
link: 'https://example.com/work',
|
||||
snippet: 'Abstract',
|
||||
year: 2019,
|
||||
citedBy: 42,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await searchTool.transformResponse!(response, {} as never)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output.searchResults).toEqual([
|
||||
{
|
||||
title: 'A cited work',
|
||||
link: 'https://example.com/work',
|
||||
snippet: 'Abstract',
|
||||
position: 1,
|
||||
},
|
||||
])
|
||||
expect(result.output.searchResults[0]).not.toHaveProperty('date')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('serper searchTool.transformResponse vertical dispatch', () => {
|
||||
const videosBody = {
|
||||
videos: [{ title: 'Clip', link: 'https://example.com/clip' }],
|
||||
organic: [{ title: 'Page', link: 'https://example.com/page' }],
|
||||
}
|
||||
|
||||
it('dispatches on the validated params, not the server-controlled response URL', async () => {
|
||||
const response = serperResponse('https://google.serper.dev/search', videosBody)
|
||||
|
||||
const result = await searchTool.transformResponse!(response, {
|
||||
query: 'q',
|
||||
apiKey: 'k',
|
||||
type: 'videos',
|
||||
})
|
||||
|
||||
expect(result.output.searchResults).toEqual([
|
||||
{ title: 'Clip', link: 'https://example.com/clip', position: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
it('reads the requested vertical when response.url is empty', async () => {
|
||||
const response = new Response(JSON.stringify(videosBody), { status: 200 })
|
||||
expect(response.url).toBe('')
|
||||
|
||||
const result = await searchTool.transformResponse!(response, {
|
||||
query: 'q',
|
||||
apiKey: 'k',
|
||||
type: 'videos',
|
||||
})
|
||||
|
||||
expect(result.output.searchResults).toEqual([
|
||||
{ title: 'Clip', link: 'https://example.com/clip', position: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores a query string appended to the response URL instead of throwing', async () => {
|
||||
const response = serperResponse('https://google.serper.dev/videos?utm=1', videosBody)
|
||||
|
||||
const result = await searchTool.transformResponse!(response, {} as never)
|
||||
|
||||
expect(result.output.searchResults).toEqual([
|
||||
{ title: 'Clip', link: 'https://example.com/clip', position: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to the search vertical when the response URL names an unknown vertical', async () => {
|
||||
const response = serperResponse('https://google.serper.dev/maps', videosBody)
|
||||
|
||||
const result = await searchTool.transformResponse!(response, {} as never)
|
||||
|
||||
expect(result.output.searchResults).toEqual([
|
||||
{ title: 'Page', link: 'https://example.com/page', position: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to the search vertical when neither params nor URL name one', async () => {
|
||||
const response = new Response(JSON.stringify(videosBody), { status: 200 })
|
||||
|
||||
const result = await searchTool.transformResponse!(response, {} as never)
|
||||
|
||||
expect(result.output.searchResults).toEqual([
|
||||
{ title: 'Page', link: 'https://example.com/page', position: 1 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('serper searchTool.request.url', () => {
|
||||
function url(type?: string): string {
|
||||
const build = searchTool.request.url as (params: Record<string, unknown>) => string
|
||||
return build({ query: 'q', apiKey: 'k', type })
|
||||
}
|
||||
|
||||
it('defaults to the search vertical', () => {
|
||||
expect(url()).toBe('https://google.serper.dev/search')
|
||||
})
|
||||
|
||||
it.each(['search', 'news', 'places', 'images', 'videos', 'shopping', 'scholar', 'patents'])(
|
||||
'builds the %s vertical URL',
|
||||
(type) => {
|
||||
expect(url(type)).toBe(`https://google.serper.dev/${type}`)
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects a vertical the transform cannot handle', () => {
|
||||
expect(() => url('maps')).toThrow(
|
||||
'Unsupported Serper search type "maps". Supported types: search, news, places, images, videos, shopping, scholar, patents.'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a path-traversing type instead of interpolating it into the URL', () => {
|
||||
expect(() => url('../v1/admin')).toThrow('Unsupported Serper search type')
|
||||
})
|
||||
})
|
||||
|
||||
+163
-49
@@ -2,6 +2,159 @@ import type { SearchParams, SearchResponse, SearchResult } from '@/tools/serper/
|
||||
import { SERPER_SEARCH_RESULT_OUTPUT_PROPERTIES } from '@/tools/serper/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
/** Every Serper vertical this tool is verified to speak. */
|
||||
type SerperSearchType = NonNullable<SearchParams['type']>
|
||||
|
||||
interface SerperVertical {
|
||||
/** Key on the Serper JSON payload holding this vertical's result array. */
|
||||
responseKey: string
|
||||
/** Projects one raw Serper item onto the unified {@link SearchResult} shape. */
|
||||
toResult: (item: Record<string, unknown>, index: number) => SearchResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyed dispatch for every supported vertical. Typing this as a `Record` over the
|
||||
* `SearchParams['type']` union makes it a compile-time completeness gate: widening the union
|
||||
* without adding the matching entry fails the build, instead of silently falling through to the
|
||||
* organic branch and returning an empty (but billed) result set.
|
||||
*/
|
||||
const SERPER_VERTICALS: Record<SerperSearchType, SerperVertical> = {
|
||||
search: {
|
||||
responseKey: 'organic',
|
||||
toResult: (item, index) => ({
|
||||
title: item.title as string,
|
||||
link: item.link as string,
|
||||
snippet: item.snippet as string | undefined,
|
||||
position: index + 1,
|
||||
date: item.date as string | undefined,
|
||||
}),
|
||||
},
|
||||
news: {
|
||||
responseKey: 'news',
|
||||
toResult: (item, index) => ({
|
||||
title: item.title as string,
|
||||
link: item.link as string,
|
||||
snippet: item.snippet as string | undefined,
|
||||
position: index + 1,
|
||||
date: item.date as string | undefined,
|
||||
imageUrl: item.imageUrl as string | undefined,
|
||||
}),
|
||||
},
|
||||
places: {
|
||||
responseKey: 'places',
|
||||
toResult: (item, index) => ({
|
||||
title: item.title as string,
|
||||
link: item.link as string,
|
||||
snippet: item.snippet as string | undefined,
|
||||
position: index + 1,
|
||||
rating: item.rating as number | undefined,
|
||||
reviews: item.reviews as number | undefined,
|
||||
address: item.address as string | undefined,
|
||||
}),
|
||||
},
|
||||
images: {
|
||||
responseKey: 'images',
|
||||
toResult: (item, index) => ({
|
||||
title: item.title as string,
|
||||
link: item.link as string,
|
||||
snippet: item.snippet as string | undefined,
|
||||
position: index + 1,
|
||||
imageUrl: item.imageUrl as string | undefined,
|
||||
}),
|
||||
},
|
||||
videos: {
|
||||
responseKey: 'videos',
|
||||
toResult: (item, index) => ({
|
||||
title: item.title as string,
|
||||
link: item.link as string,
|
||||
snippet: item.snippet as string | undefined,
|
||||
position: index + 1,
|
||||
date: item.date as string | undefined,
|
||||
source: item.source as string | undefined,
|
||||
duration: item.duration as string | undefined,
|
||||
}),
|
||||
},
|
||||
shopping: {
|
||||
responseKey: 'shopping',
|
||||
toResult: (item, index) => ({
|
||||
title: item.title as string,
|
||||
link: item.link as string,
|
||||
snippet: item.snippet as string | undefined,
|
||||
position: index + 1,
|
||||
source: item.source as string | undefined,
|
||||
price: item.price as string | undefined,
|
||||
imageUrl: item.imageUrl as string | undefined,
|
||||
}),
|
||||
},
|
||||
/**
|
||||
* `organic` is the confirmed top-level key for both `scholar` and `patents`, verified against the
|
||||
* example responses Serper publishes on the vertical tabs of serper.dev. Those are published
|
||||
* examples rather than a formal schema, so treat them as the best available source.
|
||||
*
|
||||
* Both verticals return richer per-item fields than the unified {@link SearchResult} shape
|
||||
* surfaces today (`scholar`: `publicationInfo`, `year`, `citedBy`; `patents`: `priorityDate`,
|
||||
* `filingDate`, `grantDate`, `publicationDate`, `inventor`, `assignee`, `publicationNumber`,
|
||||
* and others). Neither returns a `date` key.
|
||||
*/
|
||||
scholar: {
|
||||
responseKey: 'organic',
|
||||
toResult: (item, index) => ({
|
||||
title: item.title as string,
|
||||
link: item.link as string,
|
||||
snippet: item.snippet as string | undefined,
|
||||
position: index + 1,
|
||||
}),
|
||||
},
|
||||
patents: {
|
||||
responseKey: 'organic',
|
||||
toResult: (item, index) => ({
|
||||
title: item.title as string,
|
||||
link: item.link as string,
|
||||
snippet: item.snippet as string | undefined,
|
||||
position: index + 1,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
const SERPER_SEARCH_TYPES = new Set<string>(Object.keys(SERPER_VERTICALS))
|
||||
|
||||
const SERPER_SEARCH_TYPE_LIST = Object.keys(SERPER_VERTICALS).join(', ')
|
||||
|
||||
/**
|
||||
* Narrows a free-form `type` to a vertical the dispatch table handles. `type` is `user-or-llm`
|
||||
* visible and is interpolated into the request path, so an unhandled value must fail loudly rather
|
||||
* than reach Serper and come back as a shape this tool would flatten to an empty result set.
|
||||
*/
|
||||
function resolveSearchType(type: string | undefined): SerperSearchType {
|
||||
const candidate = type || 'search'
|
||||
if (!SERPER_SEARCH_TYPES.has(candidate)) {
|
||||
throw new Error(
|
||||
`Unsupported Serper search type "${candidate}". Supported types: ${SERPER_SEARCH_TYPE_LIST}.`
|
||||
)
|
||||
}
|
||||
return candidate as SerperSearchType
|
||||
}
|
||||
|
||||
/**
|
||||
* Recovers the vertical from a request URL. Only a fallback for callers that invoke
|
||||
* `transformResponse` without `params` — `response.url` is server-influenced, is empty on some
|
||||
* fetch/mock paths, and can carry a query string or a redirect target. Returns `undefined` when it
|
||||
* yields nothing usable — empty, unparseable, or a segment that names no known vertical — so the
|
||||
* caller falls back to the default vertical instead of dispatching on a partial value. A
|
||||
* server-controlled URL must never turn a successful response into a thrown error; only a
|
||||
* user-supplied `params.type` hard-fails, via {@link resolveSearchType}.
|
||||
*/
|
||||
function verticalFromUrl(url: string | undefined): SerperSearchType | undefined {
|
||||
if (!url) return undefined
|
||||
let segment: string | undefined
|
||||
try {
|
||||
segment = new URL(url).pathname.split('/').pop() || undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return segment && SERPER_SEARCH_TYPES.has(segment) ? (segment as SerperSearchType) : undefined
|
||||
}
|
||||
|
||||
export const searchTool: ToolConfig<SearchParams, SearchResponse> = {
|
||||
id: 'serper_search',
|
||||
name: 'Web Search',
|
||||
@@ -39,7 +192,7 @@ export const searchTool: ToolConfig<SearchParams, SearchResponse> = {
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Type of search to perform (e.g., "search", "news", "images", "videos", "places", "shopping")',
|
||||
'Type of search to perform. Must be one of "search", "news", "places", "images", "videos", "shopping", "scholar", "patents" — any other value is rejected.',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
@@ -72,7 +225,7 @@ export const searchTool: ToolConfig<SearchParams, SearchResponse> = {
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => `https://google.serper.dev/${params.type || 'search'}`,
|
||||
url: (params) => `https://google.serper.dev/${resolveSearchType(params.type)}`,
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
'X-API-KEY': params.apiKey,
|
||||
@@ -92,54 +245,15 @@ export const searchTool: ToolConfig<SearchParams, SearchResponse> = {
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
transformResponse: async (response: Response, params?: SearchParams) => {
|
||||
const data = await response.json()
|
||||
|
||||
const searchType = response.url.split('/').pop() || 'search'
|
||||
let searchResults: SearchResult[] = []
|
||||
|
||||
if (searchType === 'news') {
|
||||
searchResults =
|
||||
data.news?.map((item: any, index: number) => ({
|
||||
title: item.title,
|
||||
link: item.link,
|
||||
snippet: item.snippet,
|
||||
position: index + 1,
|
||||
date: item.date,
|
||||
imageUrl: item.imageUrl,
|
||||
})) || []
|
||||
} else if (searchType === 'places') {
|
||||
searchResults =
|
||||
data.places?.map((item: any, index: number) => ({
|
||||
title: item.title,
|
||||
link: item.link,
|
||||
snippet: item.snippet,
|
||||
position: index + 1,
|
||||
rating: item.rating,
|
||||
reviews: item.reviews,
|
||||
address: item.address,
|
||||
})) || []
|
||||
} else if (searchType === 'images') {
|
||||
searchResults =
|
||||
data.images?.map((item: any, index: number) => ({
|
||||
title: item.title,
|
||||
link: item.link,
|
||||
snippet: item.snippet,
|
||||
position: index + 1,
|
||||
imageUrl: item.imageUrl,
|
||||
})) || []
|
||||
} else {
|
||||
searchResults =
|
||||
data.organic?.map((item: any, index: number) => ({
|
||||
title: item.title,
|
||||
link: item.link,
|
||||
snippet: item.snippet,
|
||||
position: index + 1,
|
||||
// Google reports a date on many organic results, and the output schema has always declared
|
||||
// it optional — dropping it here was silently discarding it for web searches alone.
|
||||
date: item.date,
|
||||
})) || []
|
||||
}
|
||||
const vertical =
|
||||
SERPER_VERTICALS[resolveSearchType(params?.type ?? verticalFromUrl(response.url))]
|
||||
const items = data[vertical.responseKey]
|
||||
const searchResults: SearchResult[] = Array.isArray(items)
|
||||
? items.map((item, index) => vertical.toResult(item, index))
|
||||
: []
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -153,7 +267,7 @@ export const searchTool: ToolConfig<SearchParams, SearchResponse> = {
|
||||
searchResults: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Search results with titles, links, snippets, and type-specific metadata (date for news, rating for places, imageUrl for images)',
|
||||
'Search results with titles, links, snippets, and type-specific metadata (date for news, rating for places, imageUrl for images, duration/source for videos, price/source for shopping)',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: SERPER_SEARCH_RESULT_OUTPUT_PROPERTIES,
|
||||
|
||||
@@ -253,7 +253,7 @@ export interface SearchParams {
|
||||
num?: number
|
||||
gl?: string
|
||||
hl?: string
|
||||
type?: 'search' | 'news' | 'places' | 'images' | 'videos' | 'shopping'
|
||||
type?: 'search' | 'news' | 'places' | 'images' | 'videos' | 'shopping' | 'scholar' | 'patents'
|
||||
tbs?: string
|
||||
}
|
||||
|
||||
@@ -267,6 +267,8 @@ export interface SearchResult {
|
||||
source?: string
|
||||
rating?: number
|
||||
ratingCount?: number
|
||||
/** Legacy passthrough of the raw `reviews` key on places items; not an advertised output. */
|
||||
reviews?: number
|
||||
address?: string
|
||||
price?: string
|
||||
duration?: string
|
||||
|
||||
@@ -77,7 +77,7 @@ export const sixtyfourEnrichCompanyTool: ToolConfig<
|
||||
leadStruct: params.leadStruct,
|
||||
}),
|
||||
},
|
||||
url: 'https://api.sixtyfour.ai/enrich-company',
|
||||
url: 'https://api.sixtyfour.ai/company-intelligence',
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -52,7 +52,7 @@ export const sixtyfourEnrichLeadTool: ToolConfig<
|
||||
researchPlan: params.researchPlan,
|
||||
}),
|
||||
},
|
||||
url: 'https://api.sixtyfour.ai/enrich-lead',
|
||||
url: 'https://api.sixtyfour.ai/people-intelligence',
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Builds a single, traversal-safe URL path segment from an identifier that a
|
||||
* tool interpolates into a request path.
|
||||
*
|
||||
* `encodeURIComponent` alone is NOT sufficient, and this is the whole reason
|
||||
* this helper exists — do not "simplify" it back to a bare encode. `.` and `..`
|
||||
* are *unreserved* characters, so `encodeURIComponent('..') === '..'` and
|
||||
* `encodeURIComponent('.') === '.'`. The WHATWG URL parser that `fetch` uses
|
||||
* then applies RFC 3986 dot-segment removal, so the segment is normalized away
|
||||
* *after* encoding:
|
||||
*
|
||||
* ```
|
||||
* new URL('https://api.vercel.com/v1/global-config/../items').pathname
|
||||
* // => '/v1/items'
|
||||
* new URL('https://proxy.app.daytona.io/toolbox/../files/upload-v2').pathname
|
||||
* // => '/files/upload-v2'
|
||||
* ```
|
||||
*
|
||||
* That pops exactly one path segment on a fixed host with the caller's bearer
|
||||
* token still attached — including on DELETE routes. Multi-level traversal
|
||||
* (`../..`) is already blocked by encoding because the inner `/` becomes
|
||||
* `%2F`, but the single-segment case is not. These IDs are
|
||||
* `visibility: 'user-or-llm'`, so prompt injection controls them.
|
||||
*
|
||||
* A value that is exactly `.` or `..` after trimming is therefore rejected
|
||||
* outright, as are empty values and any value still carrying a `/` or `\`
|
||||
* separator (defense in depth — encoding already neutralizes those).
|
||||
*
|
||||
* Note that a dot *inside* a longer segment is legitimate and preserved:
|
||||
* `example.com`, `my-app.vercel.app`, and `..foo` all pass through untouched.
|
||||
*
|
||||
* @param value - The raw identifier, typically LLM- or user-supplied.
|
||||
* @param paramName - The parameter name, used to name the offender in errors.
|
||||
* @returns The trimmed, percent-encoded segment, safe to interpolate.
|
||||
* @throws If the value is empty, a dot segment, or contains a path separator.
|
||||
*/
|
||||
export function safeUrlPathSegment(value: string, paramName: string): string {
|
||||
const trimmed = typeof value === 'string' ? value.trim() : ''
|
||||
|
||||
if (!trimmed) {
|
||||
throw new Error(`${paramName} is required`)
|
||||
}
|
||||
|
||||
if (trimmed === '.' || trimmed === '..') {
|
||||
throw new Error(`${paramName} cannot be "${trimmed}" (path traversal is not allowed)`)
|
||||
}
|
||||
|
||||
if (trimmed.includes('/') || trimmed.includes('\\')) {
|
||||
throw new Error(`${paramName} cannot contain a path separator`)
|
||||
}
|
||||
|
||||
return encodeURIComponent(trimmed)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type {
|
||||
VercelAddProjectDomainParams,
|
||||
VercelAddProjectDomainResponse,
|
||||
@@ -70,7 +71,7 @@ export const vercelAddProjectDomainTool: ToolConfig<
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v10/projects/${params.projectId.trim()}/domains${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v10/projects/${safeUrlPathSegment(params.projectId, 'projectId')}/domains${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params: VercelAddProjectDomainParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type {
|
||||
VercelCancelDeploymentParams,
|
||||
VercelCancelDeploymentResponse,
|
||||
@@ -46,7 +47,7 @@ export const vercelCancelDeploymentTool: ToolConfig<
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v12/deployments/${params.deploymentId.trim()}/cancel${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v12/deployments/${safeUrlPathSegment(params.deploymentId, 'deploymentId')}/cancel${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'PATCH',
|
||||
headers: (params: VercelCancelDeploymentParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type { VercelCreateAliasParams, VercelCreateAliasResponse } from '@/tools/vercel/types'
|
||||
|
||||
export const vercelCreateAliasTool: ToolConfig<VercelCreateAliasParams, VercelCreateAliasResponse> =
|
||||
@@ -54,7 +55,7 @@ export const vercelCreateAliasTool: ToolConfig<VercelCreateAliasParams, VercelCr
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v2/deployments/${params.deploymentId.trim()}/aliases${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v2/deployments/${safeUrlPathSegment(params.deploymentId, 'deploymentId')}/aliases${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params: VercelCreateAliasParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type { VercelCheckResponse, VercelCreateCheckParams } from '@/tools/vercel/types'
|
||||
|
||||
export const vercelCreateCheckTool: ToolConfig<VercelCreateCheckParams, VercelCheckResponse> = {
|
||||
@@ -76,7 +77,7 @@ export const vercelCreateCheckTool: ToolConfig<VercelCreateCheckParams, VercelCh
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v1/deployments/${params.deploymentId.trim()}/checks${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v1/deployments/${safeUrlPathSegment(params.deploymentId, 'deploymentId')}/checks${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params: VercelCreateCheckParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type {
|
||||
VercelCreateDnsRecordParams,
|
||||
VercelCreateDnsRecordResponse,
|
||||
@@ -124,7 +125,7 @@ export const vercelCreateDnsRecordTool: ToolConfig<
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v2/domains/${params.domain.trim()}/records${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v2/domains/${safeUrlPathSegment(params.domain, 'domain')}/records${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params: VercelCreateDnsRecordParams) => ({
|
||||
|
||||
@@ -39,7 +39,7 @@ export const vercelCreateEdgeConfigTool: ToolConfig<
|
||||
const query = new URLSearchParams()
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v1/edge-config${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v1/global-config${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params: VercelCreateEdgeConfigParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type { VercelCreateEnvVarParams, VercelCreateEnvVarResponse } from '@/tools/vercel/types'
|
||||
|
||||
export const vercelCreateEnvVarTool: ToolConfig<
|
||||
@@ -79,7 +80,7 @@ export const vercelCreateEnvVarTool: ToolConfig<
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v10/projects/${params.projectId.trim()}/env${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v10/projects/${safeUrlPathSegment(params.projectId, 'projectId')}/env${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params: VercelCreateEnvVarParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type { VercelDeleteAliasParams, VercelDeleteAliasResponse } from '@/tools/vercel/types'
|
||||
|
||||
export const vercelDeleteAliasTool: ToolConfig<VercelDeleteAliasParams, VercelDeleteAliasResponse> =
|
||||
@@ -41,7 +42,7 @@ export const vercelDeleteAliasTool: ToolConfig<VercelDeleteAliasParams, VercelDe
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v2/aliases/${params.aliasId.trim()}${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v2/aliases/${safeUrlPathSegment(params.aliasId, 'aliasId')}${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'DELETE',
|
||||
headers: (params: VercelDeleteAliasParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type {
|
||||
VercelDeleteDeploymentParams,
|
||||
VercelDeleteDeploymentResponse,
|
||||
@@ -50,7 +51,7 @@ export const vercelDeleteDeploymentTool: ToolConfig<
|
||||
query.set('url', id)
|
||||
}
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v13/deployments/${id}${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v13/deployments/${safeUrlPathSegment(id, 'deploymentId')}${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'DELETE',
|
||||
headers: (params: VercelDeleteDeploymentParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type {
|
||||
VercelDeleteDnsRecordParams,
|
||||
VercelDeleteDnsRecordResponse,
|
||||
@@ -52,7 +53,7 @@ export const vercelDeleteDnsRecordTool: ToolConfig<
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v2/domains/${params.domain.trim()}/records/${params.recordId.trim()}${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v2/domains/${safeUrlPathSegment(params.domain, 'domain')}/records/${safeUrlPathSegment(params.recordId, 'recordId')}${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'DELETE',
|
||||
headers: (params: VercelDeleteDnsRecordParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type { VercelDeleteDomainParams, VercelDeleteDomainResponse } from '@/tools/vercel/types'
|
||||
|
||||
export const vercelDeleteDomainTool: ToolConfig<
|
||||
@@ -43,7 +44,7 @@ export const vercelDeleteDomainTool: ToolConfig<
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v6/domains/${params.domain.trim()}${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v6/domains/${safeUrlPathSegment(params.domain, 'domain')}${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'DELETE',
|
||||
headers: (params: VercelDeleteDomainParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type {
|
||||
VercelDeleteEdgeConfigParams,
|
||||
VercelDeleteEdgeConfigResponse,
|
||||
@@ -39,7 +40,8 @@ export const vercelDeleteEdgeConfigTool: ToolConfig<
|
||||
const query = new URLSearchParams()
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v1/edge-config/${params.edgeConfigId.trim()}${qs ? `?${qs}` : ''}`
|
||||
const edgeConfigId = safeUrlPathSegment(params.edgeConfigId, 'edgeConfigId')
|
||||
return `https://api.vercel.com/v1/global-config/${edgeConfigId}${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'DELETE',
|
||||
headers: (params: VercelDeleteEdgeConfigParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type { VercelDeleteEnvVarParams, VercelDeleteEnvVarResponse } from '@/tools/vercel/types'
|
||||
|
||||
export const vercelDeleteEnvVarTool: ToolConfig<
|
||||
@@ -49,7 +50,7 @@ export const vercelDeleteEnvVarTool: ToolConfig<
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v9/projects/${params.projectId.trim()}/env/${params.envId.trim()}${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v9/projects/${safeUrlPathSegment(params.projectId, 'projectId')}/env/${safeUrlPathSegment(params.envId, 'envId')}${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'DELETE',
|
||||
headers: (params: VercelDeleteEnvVarParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type { VercelDeleteProjectParams, VercelDeleteProjectResponse } from '@/tools/vercel/types'
|
||||
|
||||
export const vercelDeleteProjectTool: ToolConfig<
|
||||
@@ -43,7 +44,7 @@ export const vercelDeleteProjectTool: ToolConfig<
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v9/projects/${params.projectId.trim()}${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v9/projects/${safeUrlPathSegment(params.projectId, 'projectId')}${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'DELETE',
|
||||
headers: (params: VercelDeleteProjectParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type { VercelDeleteWebhookParams, VercelDeleteWebhookResponse } from '@/tools/vercel/types'
|
||||
|
||||
export const vercelDeleteWebhookTool: ToolConfig<
|
||||
@@ -36,7 +37,7 @@ export const vercelDeleteWebhookTool: ToolConfig<
|
||||
const query = new URLSearchParams()
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v1/webhooks/${params.webhookId.trim()}${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v1/webhooks/${safeUrlPathSegment(params.webhookId, 'webhookId')}${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'DELETE',
|
||||
headers: (params: VercelDeleteWebhookParams) => ({
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*
|
||||
* Guards every Vercel tool against path traversal through an LLM-writable ID
|
||||
* that gets interpolated into the request path.
|
||||
*
|
||||
* These IDs are `visibility: 'user-or-llm'`, so prompt injection controls them.
|
||||
* Interpolating one raw let a value like `../../v9/projects/x` escape its API
|
||||
* prefix once `fetch` normalized the URL, re-aiming the request (and the user's
|
||||
* Vercel bearer token) at an arbitrary Vercel resource — including on DELETE.
|
||||
* `assertRequestUrlMatchesTrust` in `tools/request-transport.ts` only applies
|
||||
* its canonicalization guard to internal `/api/` routes, so nothing downstream
|
||||
* catches this.
|
||||
*
|
||||
* Wrapping the ID in `encodeURIComponent` is NOT enough, which is why the
|
||||
* vector list below includes the bare `.` and `..` segments: both are made of
|
||||
* unreserved characters, so they survive encoding untouched and the URL parser
|
||||
* then removes them as dot segments, popping one path segment off a fixed host.
|
||||
* Every assertion here resolves the built URL with `new URL(...)` — the same
|
||||
* normalization `fetch` performs — rather than string-matching the template
|
||||
* output, because string matching is exactly what let this through.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { vercelDeleteEdgeConfigTool } from '@/tools/vercel/delete_edge_config'
|
||||
import { vercelGetEdgeConfigTool } from '@/tools/vercel/get_edge_config'
|
||||
import { vercelGetEdgeConfigItemsTool } from '@/tools/vercel/get_edge_config_items'
|
||||
import * as vercelTools from '@/tools/vercel/index'
|
||||
import { vercelUpdateEdgeConfigItemsTool } from '@/tools/vercel/update_edge_config_items'
|
||||
|
||||
const BASE_PATH = '/v1/global-config/'
|
||||
|
||||
/**
|
||||
* The bare `.` and `..` entries are the whole point: their omission is why an
|
||||
* earlier `encodeURIComponent`-only fix looked correct while the hole was live.
|
||||
*/
|
||||
const TRAVERSAL_IDS = [
|
||||
'..',
|
||||
'.',
|
||||
' .. ',
|
||||
'../../v9/projects/prod-site',
|
||||
'..%2f..%2fv9/projects/prod-site',
|
||||
'ecfg_abc/../../../v9/projects/prod-site',
|
||||
'ecfg_abc?teamId=attacker',
|
||||
'ecfg_abc#fragment',
|
||||
'ecfg_abc/items/../../../v2/domains',
|
||||
'\\..\\..',
|
||||
] as const
|
||||
|
||||
/** Values a real user legitimately supplies; none may be rejected or altered. */
|
||||
const LEGITIMATE_IDS = [
|
||||
'ecfg_abc123',
|
||||
'flags',
|
||||
'feature-flags',
|
||||
'prj_2W7QpN8xkE4hVvRt6bLd',
|
||||
'dpl_9aBcDeFgHiJkLmNoPqRsTuVwXyZ',
|
||||
'example.com',
|
||||
'sub.example.co.uk',
|
||||
'my-app.vercel.app',
|
||||
'..foo',
|
||||
'foo..',
|
||||
'v1.2.3',
|
||||
] as const
|
||||
|
||||
const SAFE_ID = 'SAFEID'
|
||||
|
||||
type AnyTool = ToolConfig<any, any>
|
||||
|
||||
function isVercelTool(value: unknown): value is AnyTool {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as AnyTool).id === 'string' &&
|
||||
(value as AnyTool).id.startsWith('vercel_')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a param object for a tool, filling every declared string param with
|
||||
* `value` so whichever one reaches the path is exercised.
|
||||
*/
|
||||
function buildParams(tool: AnyTool, value: string): Record<string, unknown> {
|
||||
const params: Record<string, unknown> = { apiKey: 'token' }
|
||||
for (const [name, def] of Object.entries(tool.params ?? {})) {
|
||||
if (name === 'apiKey') continue
|
||||
const type = (def as { type?: string }).type
|
||||
if (type === 'json' || type === 'array') {
|
||||
params[name] = []
|
||||
} else if (type === 'number') {
|
||||
params[name] = 1
|
||||
} else if (type === 'boolean') {
|
||||
params[name] = false
|
||||
} else {
|
||||
params[name] = value
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
function buildPath(tool: AnyTool, value: string): string {
|
||||
const url = tool.request?.url
|
||||
if (typeof url !== 'function') {
|
||||
throw new Error(`${tool.id} does not build its URL from params`)
|
||||
}
|
||||
return new URL(url(buildParams(tool, value) as any)).pathname
|
||||
}
|
||||
|
||||
function segmentsOf(pathname: string): string[] {
|
||||
return pathname.split('/')
|
||||
}
|
||||
|
||||
const DYNAMIC_PATH_TOOLS = Object.values(vercelTools)
|
||||
.filter(isVercelTool)
|
||||
.filter((tool) => typeof tool.request?.url === 'function')
|
||||
.filter((tool) => {
|
||||
try {
|
||||
return buildPath(tool, SAFE_ID).includes(SAFE_ID)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
.map((tool) => ({ name: tool.id, tool }))
|
||||
|
||||
describe('vercel path-ID traversal safety', () => {
|
||||
it('covers every Vercel tool that interpolates an ID into its path', () => {
|
||||
expect(DYNAMIC_PATH_TOOLS.length).toBeGreaterThanOrEqual(20)
|
||||
})
|
||||
|
||||
describe.each(DYNAMIC_PATH_TOOLS)('$name', ({ tool }) => {
|
||||
const baseline = segmentsOf(buildPath(tool, SAFE_ID))
|
||||
|
||||
it.each(TRAVERSAL_IDS)('cannot reshape the path with %j', (value) => {
|
||||
let path: string
|
||||
try {
|
||||
path = buildPath(tool, value)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const actual = segmentsOf(path)
|
||||
expect(actual).toHaveLength(baseline.length)
|
||||
baseline.forEach((segment, index) => {
|
||||
if (segment === SAFE_ID) return
|
||||
expect(actual[index]).toBe(segment)
|
||||
})
|
||||
})
|
||||
|
||||
it.each(LEGITIMATE_IDS)('passes %j through unchanged', (value) => {
|
||||
const actual = segmentsOf(buildPath(tool, value))
|
||||
|
||||
expect(actual).toHaveLength(baseline.length)
|
||||
baseline.forEach((segment, index) => {
|
||||
expect(actual[index]).toBe(segment === SAFE_ID ? value : segment)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const EDGE_CONFIG_TOOLS: ReadonlyArray<{ name: string; tool: AnyTool }> = [
|
||||
{ name: 'vercel_get_edge_config', tool: vercelGetEdgeConfigTool },
|
||||
{ name: 'vercel_delete_edge_config', tool: vercelDeleteEdgeConfigTool },
|
||||
{ name: 'vercel_get_edge_config_items', tool: vercelGetEdgeConfigItemsTool },
|
||||
{ name: 'vercel_update_edge_config_items', tool: vercelUpdateEdgeConfigItemsTool },
|
||||
]
|
||||
|
||||
function buildEdgeConfigUrl(tool: AnyTool, edgeConfigId: string): URL {
|
||||
const url = tool.request?.url
|
||||
if (typeof url !== 'function') {
|
||||
throw new Error(`${tool.id} does not build its URL from params`)
|
||||
}
|
||||
return new URL(url({ apiKey: 'token', edgeConfigId, items: [] }))
|
||||
}
|
||||
|
||||
describe.each(EDGE_CONFIG_TOOLS)('$name edgeConfigId path safety', ({ tool }) => {
|
||||
it.each(TRAVERSAL_IDS)('keeps %j inside /v1/global-config/', (edgeConfigId) => {
|
||||
let url: URL
|
||||
try {
|
||||
url = buildEdgeConfigUrl(tool, edgeConfigId)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
expect(url.origin).toBe('https://api.vercel.com')
|
||||
expect(url.pathname.startsWith(BASE_PATH)).toBe(true)
|
||||
expect(url.pathname).not.toContain('/v9/')
|
||||
expect(url.pathname).not.toContain('/v2/')
|
||||
})
|
||||
|
||||
it('rejects a bare dot-dot segment instead of silently popping the prefix', () => {
|
||||
expect(() => buildEdgeConfigUrl(tool, '..')).toThrow(/edgeConfigId/)
|
||||
})
|
||||
|
||||
it('rejects a bare dot segment', () => {
|
||||
expect(() => buildEdgeConfigUrl(tool, '.')).toThrow(/edgeConfigId/)
|
||||
})
|
||||
|
||||
it('does not let the id inject query parameters', () => {
|
||||
const url = buildEdgeConfigUrl(tool, 'ecfg_abc?teamId=attacker')
|
||||
|
||||
expect(url.searchParams.get('teamId')).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves a legitimate store id verbatim', () => {
|
||||
const url = buildEdgeConfigUrl(tool, ' ecfg_abc123 ')
|
||||
|
||||
expect(url.pathname.startsWith(`${BASE_PATH}ecfg_abc123`)).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves a legitimate slug verbatim', () => {
|
||||
const url = buildEdgeConfigUrl(tool, 'feature-flags')
|
||||
|
||||
expect(url.pathname.startsWith(`${BASE_PATH}feature-flags`)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type { VercelGetAliasParams, VercelGetAliasResponse } from '@/tools/vercel/types'
|
||||
|
||||
export const vercelGetAliasTool: ToolConfig<VercelGetAliasParams, VercelGetAliasResponse> = {
|
||||
@@ -40,7 +41,7 @@ export const vercelGetAliasTool: ToolConfig<VercelGetAliasParams, VercelGetAlias
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v4/aliases/${params.aliasId.trim()}${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v4/aliases/${safeUrlPathSegment(params.aliasId, 'aliasId')}${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: VercelGetAliasParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type { VercelCheckResponse, VercelGetCheckParams } from '@/tools/vercel/types'
|
||||
|
||||
export const vercelGetCheckTool: ToolConfig<VercelGetCheckParams, VercelCheckResponse> = {
|
||||
@@ -46,7 +47,7 @@ export const vercelGetCheckTool: ToolConfig<VercelGetCheckParams, VercelCheckRes
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v1/deployments/${params.deploymentId.trim()}/checks/${params.checkId.trim()}${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v1/deployments/${safeUrlPathSegment(params.deploymentId, 'deploymentId')}/checks/${safeUrlPathSegment(params.checkId, 'checkId')}${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: VercelGetCheckParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type { VercelGetDeploymentParams, VercelGetDeploymentResponse } from '@/tools/vercel/types'
|
||||
|
||||
export const vercelGetDeploymentTool: ToolConfig<
|
||||
@@ -50,7 +51,7 @@ export const vercelGetDeploymentTool: ToolConfig<
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v13/deployments/${params.deploymentId.trim()}${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v13/deployments/${safeUrlPathSegment(params.deploymentId, 'deploymentId')}${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: VercelGetDeploymentParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type {
|
||||
VercelGetDeploymentEventsParams,
|
||||
VercelGetDeploymentEventsResponse,
|
||||
@@ -81,7 +82,7 @@ export const vercelGetDeploymentEventsTool: ToolConfig<
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v3/deployments/${params.deploymentId.trim()}/events${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v3/deployments/${safeUrlPathSegment(params.deploymentId, 'deploymentId')}/events${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: VercelGetDeploymentEventsParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type { VercelGetDomainParams, VercelGetDomainResponse } from '@/tools/vercel/types'
|
||||
|
||||
export const vercelGetDomainTool: ToolConfig<VercelGetDomainParams, VercelGetDomainResponse> = {
|
||||
@@ -40,7 +41,7 @@ export const vercelGetDomainTool: ToolConfig<VercelGetDomainParams, VercelGetDom
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v5/domains/${params.domain.trim()}${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v5/domains/${safeUrlPathSegment(params.domain, 'domain')}${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: VercelGetDomainParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type {
|
||||
VercelGetDomainConfigParams,
|
||||
VercelGetDomainConfigResponse,
|
||||
@@ -46,7 +47,7 @@ export const vercelGetDomainConfigTool: ToolConfig<
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v6/domains/${params.domain.trim()}/config${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v6/domains/${safeUrlPathSegment(params.domain, 'domain')}/config${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: VercelGetDomainConfigParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type { VercelGetEdgeConfigParams, VercelGetEdgeConfigResponse } from '@/tools/vercel/types'
|
||||
|
||||
export const vercelGetEdgeConfigTool: ToolConfig<
|
||||
@@ -36,7 +37,8 @@ export const vercelGetEdgeConfigTool: ToolConfig<
|
||||
const query = new URLSearchParams()
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v1/edge-config/${params.edgeConfigId.trim()}${qs ? `?${qs}` : ''}`
|
||||
const edgeConfigId = safeUrlPathSegment(params.edgeConfigId, 'edgeConfigId')
|
||||
return `https://api.vercel.com/v1/global-config/${edgeConfigId}${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: VercelGetEdgeConfigParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type {
|
||||
VercelGetEdgeConfigItemsParams,
|
||||
VercelGetEdgeConfigItemsResponse,
|
||||
@@ -39,7 +40,8 @@ export const vercelGetEdgeConfigItemsTool: ToolConfig<
|
||||
const query = new URLSearchParams()
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v1/edge-config/${params.edgeConfigId.trim()}/items${qs ? `?${qs}` : ''}`
|
||||
const edgeConfigId = safeUrlPathSegment(params.edgeConfigId, 'edgeConfigId')
|
||||
return `https://api.vercel.com/v1/global-config/${edgeConfigId}/items${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: VercelGetEdgeConfigItemsParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type { VercelGetEnvVarsParams, VercelGetEnvVarsResponse } from '@/tools/vercel/types'
|
||||
|
||||
export const vercelGetEnvVarsTool: ToolConfig<VercelGetEnvVarsParams, VercelGetEnvVarsResponse> = {
|
||||
@@ -55,7 +56,7 @@ export const vercelGetEnvVarsTool: ToolConfig<VercelGetEnvVarsParams, VercelGetE
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v10/projects/${params.projectId.trim()}/env${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v10/projects/${safeUrlPathSegment(params.projectId, 'projectId')}/env${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: VercelGetEnvVarsParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type { VercelGetProjectParams, VercelGetProjectResponse } from '@/tools/vercel/types'
|
||||
|
||||
export const vercelGetProjectTool: ToolConfig<VercelGetProjectParams, VercelGetProjectResponse> = {
|
||||
@@ -40,7 +41,7 @@ export const vercelGetProjectTool: ToolConfig<VercelGetProjectParams, VercelGetP
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v9/projects/${params.projectId.trim()}${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v9/projects/${safeUrlPathSegment(params.projectId, 'projectId')}${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: VercelGetProjectParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type { VercelGetTeamParams, VercelGetTeamResponse } from '@/tools/vercel/types'
|
||||
|
||||
export const vercelGetTeamTool: ToolConfig<VercelGetTeamParams, VercelGetTeamResponse> = {
|
||||
@@ -23,7 +24,8 @@ export const vercelGetTeamTool: ToolConfig<VercelGetTeamParams, VercelGetTeamRes
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params: VercelGetTeamParams) => `https://api.vercel.com/v2/teams/${params.teamId.trim()}`,
|
||||
url: (params: VercelGetTeamParams) =>
|
||||
`https://api.vercel.com/v2/teams/${safeUrlPathSegment(params.teamId, 'teamId')}`,
|
||||
method: 'GET',
|
||||
headers: (params: VercelGetTeamParams) => ({
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type { VercelGetWebhookParams, VercelGetWebhookResponse } from '@/tools/vercel/types'
|
||||
|
||||
export const vercelGetWebhookTool: ToolConfig<VercelGetWebhookParams, VercelGetWebhookResponse> = {
|
||||
@@ -33,7 +34,7 @@ export const vercelGetWebhookTool: ToolConfig<VercelGetWebhookParams, VercelGetW
|
||||
const query = new URLSearchParams()
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v1/webhooks/${params.webhookId.trim()}${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v1/webhooks/${safeUrlPathSegment(params.webhookId, 'webhookId')}${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: VercelGetWebhookParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type { VercelListChecksParams, VercelListChecksResponse } from '@/tools/vercel/types'
|
||||
|
||||
export const vercelListChecksTool: ToolConfig<VercelListChecksParams, VercelListChecksResponse> = {
|
||||
@@ -40,7 +41,7 @@ export const vercelListChecksTool: ToolConfig<VercelListChecksParams, VercelList
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v1/deployments/${params.deploymentId.trim()}/checks${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v1/deployments/${safeUrlPathSegment(params.deploymentId, 'deploymentId')}/checks${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: VercelListChecksParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type {
|
||||
VercelListDeploymentFilesParams,
|
||||
VercelListDeploymentFilesResponse,
|
||||
@@ -46,7 +47,7 @@ export const vercelListDeploymentFilesTool: ToolConfig<
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v6/deployments/${params.deploymentId.trim()}/files${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v6/deployments/${safeUrlPathSegment(params.deploymentId, 'deploymentId')}/files${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: VercelListDeploymentFilesParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type { VercelListDnsRecordsParams, VercelListDnsRecordsResponse } from '@/tools/vercel/types'
|
||||
|
||||
export const vercelListDnsRecordsTool: ToolConfig<
|
||||
@@ -50,7 +51,7 @@ export const vercelListDnsRecordsTool: ToolConfig<
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v5/domains/${params.domain.trim()}/records${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v5/domains/${safeUrlPathSegment(params.domain, 'domain')}/records${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: VercelListDnsRecordsParams) => ({
|
||||
|
||||
@@ -33,7 +33,7 @@ export const vercelListEdgeConfigsTool: ToolConfig<
|
||||
const query = new URLSearchParams()
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v1/edge-config${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v1/global-config${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: VercelListEdgeConfigsParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type {
|
||||
VercelListProjectDomainsParams,
|
||||
VercelListProjectDomainsResponse,
|
||||
@@ -53,7 +54,7 @@ export const vercelListProjectDomainsTool: ToolConfig<
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
if (params.limit) query.set('limit', String(params.limit))
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v9/projects/${params.projectId.trim()}/domains${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v9/projects/${safeUrlPathSegment(params.projectId, 'projectId')}/domains${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: VercelListProjectDomainsParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type {
|
||||
VercelListTeamMembersParams,
|
||||
VercelListTeamMembersResponse,
|
||||
@@ -68,7 +69,7 @@ export const vercelListTeamMembersTool: ToolConfig<
|
||||
if (params.until) query.set('until', String(params.until))
|
||||
if (params.search) query.set('search', params.search.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v3/teams/${params.teamId.trim()}/members${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v3/teams/${safeUrlPathSegment(params.teamId, 'teamId')}/members${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: VercelListTeamMembersParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type { VercelPauseProjectParams, VercelPauseProjectResponse } from '@/tools/vercel/types'
|
||||
|
||||
export const vercelPauseProjectTool: ToolConfig<
|
||||
@@ -43,7 +44,7 @@ export const vercelPauseProjectTool: ToolConfig<
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v1/projects/${params.projectId.trim()}/pause${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v1/projects/${safeUrlPathSegment(params.projectId, 'projectId')}/pause${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params: VercelPauseProjectParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type {
|
||||
VercelPromoteDeploymentParams,
|
||||
VercelPromoteDeploymentResponse,
|
||||
@@ -52,7 +53,7 @@ export const vercelPromoteDeploymentTool: ToolConfig<
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v10/projects/${params.projectId.trim()}/promote/${params.deploymentId.trim()}${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v10/projects/${safeUrlPathSegment(params.projectId, 'projectId')}/promote/${safeUrlPathSegment(params.deploymentId, 'deploymentId')}${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params: VercelPromoteDeploymentParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type {
|
||||
VercelRemoveProjectDomainParams,
|
||||
VercelRemoveProjectDomainResponse,
|
||||
@@ -52,7 +53,7 @@ export const vercelRemoveProjectDomainTool: ToolConfig<
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v9/projects/${params.projectId.trim()}/domains/${params.domain.trim()}${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v9/projects/${safeUrlPathSegment(params.projectId, 'projectId')}/domains/${safeUrlPathSegment(params.domain, 'domain')}${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'DELETE',
|
||||
headers: (params: VercelRemoveProjectDomainParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type { VercelRerequestCheckParams, VercelRerequestCheckResponse } from '@/tools/vercel/types'
|
||||
|
||||
export const vercelRerequestCheckTool: ToolConfig<
|
||||
@@ -56,7 +57,7 @@ export const vercelRerequestCheckTool: ToolConfig<
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
if (params.autoUpdate !== undefined) query.set('autoUpdate', String(params.autoUpdate))
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v1/deployments/${params.deploymentId.trim()}/checks/${params.checkId.trim()}/rerequest${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v1/deployments/${safeUrlPathSegment(params.deploymentId, 'deploymentId')}/checks/${safeUrlPathSegment(params.checkId, 'checkId')}/rerequest${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params: VercelRerequestCheckParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type { VercelUnpauseProjectParams, VercelUnpauseProjectResponse } from '@/tools/vercel/types'
|
||||
|
||||
export const vercelUnpauseProjectTool: ToolConfig<
|
||||
@@ -43,7 +44,7 @@ export const vercelUnpauseProjectTool: ToolConfig<
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v1/projects/${params.projectId.trim()}/unpause${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v1/projects/${safeUrlPathSegment(params.projectId, 'projectId')}/unpause${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params: VercelUnpauseProjectParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type { VercelCheckResponse, VercelUpdateCheckParams } from '@/tools/vercel/types'
|
||||
|
||||
export const vercelUpdateCheckTool: ToolConfig<VercelUpdateCheckParams, VercelCheckResponse> = {
|
||||
@@ -88,7 +89,7 @@ export const vercelUpdateCheckTool: ToolConfig<VercelUpdateCheckParams, VercelCh
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v1/deployments/${params.deploymentId.trim()}/checks/${params.checkId.trim()}${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v1/deployments/${safeUrlPathSegment(params.deploymentId, 'deploymentId')}/checks/${safeUrlPathSegment(params.checkId, 'checkId')}${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'PATCH',
|
||||
headers: (params: VercelUpdateCheckParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type {
|
||||
VercelUpdateDnsRecordParams,
|
||||
VercelUpdateDnsRecordResponse,
|
||||
@@ -124,7 +125,7 @@ export const vercelUpdateDnsRecordTool: ToolConfig<
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v1/domains/records/${params.recordId.trim()}${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v1/domains/records/${safeUrlPathSegment(params.recordId, 'recordId')}${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'PATCH',
|
||||
headers: (params: VercelUpdateDnsRecordParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type {
|
||||
VercelUpdateEdgeConfigItemsParams,
|
||||
VercelUpdateEdgeConfigItemsResponse,
|
||||
@@ -46,7 +47,8 @@ export const vercelUpdateEdgeConfigItemsTool: ToolConfig<
|
||||
const query = new URLSearchParams()
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v1/edge-config/${params.edgeConfigId.trim()}/items${qs ? `?${qs}` : ''}`
|
||||
const edgeConfigId = safeUrlPathSegment(params.edgeConfigId, 'edgeConfigId')
|
||||
return `https://api.vercel.com/v1/global-config/${edgeConfigId}/items${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'PATCH',
|
||||
headers: (params: VercelUpdateEdgeConfigItemsParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type { VercelUpdateEnvVarParams, VercelUpdateEnvVarResponse } from '@/tools/vercel/types'
|
||||
|
||||
export const vercelUpdateEnvVarTool: ToolConfig<
|
||||
@@ -85,7 +86,7 @@ export const vercelUpdateEnvVarTool: ToolConfig<
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v9/projects/${params.projectId.trim()}/env/${params.envId.trim()}${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v9/projects/${safeUrlPathSegment(params.projectId, 'projectId')}/env/${safeUrlPathSegment(params.envId, 'envId')}${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'PATCH',
|
||||
headers: (params: VercelUpdateEnvVarParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type { VercelUpdateProjectParams, VercelUpdateProjectResponse } from '@/tools/vercel/types'
|
||||
|
||||
export const vercelUpdateProjectTool: ToolConfig<
|
||||
@@ -91,7 +92,7 @@ export const vercelUpdateProjectTool: ToolConfig<
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v9/projects/${params.projectId.trim()}${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v9/projects/${safeUrlPathSegment(params.projectId, 'projectId')}${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'PATCH',
|
||||
headers: (params: VercelUpdateProjectParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type {
|
||||
VercelUpdateProjectDomainParams,
|
||||
VercelUpdateProjectDomainResponse,
|
||||
@@ -70,7 +71,7 @@ export const vercelUpdateProjectDomainTool: ToolConfig<
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v9/projects/${params.projectId.trim()}/domains/${params.domain.trim()}${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v9/projects/${safeUrlPathSegment(params.projectId, 'projectId')}/domains/${safeUrlPathSegment(params.domain, 'domain')}${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'PATCH',
|
||||
headers: (params: VercelUpdateProjectDomainParams) => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { safeUrlPathSegment } from '@/tools/url-path'
|
||||
import type {
|
||||
VercelVerifyProjectDomainParams,
|
||||
VercelVerifyProjectDomainResponse,
|
||||
@@ -52,7 +53,7 @@ export const vercelVerifyProjectDomainTool: ToolConfig<
|
||||
if (params.teamId) query.set('teamId', params.teamId.trim())
|
||||
if (params.slug) query.set('slug', params.slug.trim())
|
||||
const qs = query.toString()
|
||||
return `https://api.vercel.com/v9/projects/${params.projectId.trim()}/domains/${params.domain.trim()}/verify${qs ? `?${qs}` : ''}`
|
||||
return `https://api.vercel.com/v9/projects/${safeUrlPathSegment(params.projectId, 'projectId')}/domains/${safeUrlPathSegment(params.domain, 'domain')}/verify${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params: VercelVerifyProjectDomainParams) => ({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"updatedAt": "2026-08-25",
|
||||
"updatedAt": "2026-08-26",
|
||||
"integrations": [
|
||||
{
|
||||
"type": "onepassword",
|
||||
@@ -7418,11 +7418,11 @@
|
||||
"operations": [
|
||||
{
|
||||
"name": "Find Email",
|
||||
"description": "Find a verified B2B email address from a full name and company domain or name. Uses the Enrow async finder — submits a search and polls until the result is ready. Costs 1 credit per valid email found. (https://enrow.readme.io/reference/find-single-email)"
|
||||
"description": "Find a verified B2B email address from a full name and company domain or name. Uses the Enrow async finder — submits a search and polls until the result is ready. Costs 1 credit per valid email found. (https://docs.enrow.io/api-reference/email-finder/find-single)"
|
||||
},
|
||||
{
|
||||
"name": "Verify Email",
|
||||
"description": "Verify the deliverability of an email address using the Enrow async verifier. Submits a verification request and polls until the result is ready. Costs 0.25 credits per verification. (https://enrow.readme.io/reference/verify-single-email)"
|
||||
"description": "Verify the deliverability of an email address using the Enrow async verifier. Submits a verification request and polls until the result is ready. Costs 0.25 credits per verification. (https://docs.enrow.io/api-reference/email-verifier/verify-single)"
|
||||
}
|
||||
],
|
||||
"operationCount": 2,
|
||||
|
||||
Reference in New Issue
Block a user