Skip to content

Commit 2fe4b82

Browse files
committed
fix(agiloft): settle encoding refusals instead of returning a retryable 500
Pre-landing review findings. The create body was encoded inside the request builder, so a field value Agiloft cannot encode threw out through the executor and became a 500. The tool runner retries 500s, and an unencodable field is a permanent refusal, not a transient fault. Encoding now happens before the request is issued and answers 200 with success:false like every other create failure. Record data reaches the body builders from workflow input, so a field named after a reserved parameter - $table, $KB, $login, $password - appended a second occurrence of it and let that data choose the table the record lands in or the credentials the call runs under. Reserved names are now refused. Natural language search returned an Agiloft refusal as a 500 while its six sibling operations return 200 with success:false, so a refused search was retried. It now follows the same convention, and its test pins the status rather than only the body. Also bounds both create error messages to 300 characters, matching the alrest reader, so an unmatched HTML error page cannot be relayed whole into the tool response and the workflow log; drops a pagination value that does not read as a whole number rather than forwarding it for Agiloft to ignore; removes the alrest collection URL builder left dead by the move to EWCreate; and corrects three doc comments the move left describing the wrong function or surface.
1 parent b324938 commit 2fe4b82

7 files changed

Lines changed: 172 additions & 30 deletions

File tree

apps/sim/app/api/tools/agiloft/create_record/route.test.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,16 +261,39 @@ describe('EWCreate', () => {
261261
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
262262
})
263263

264-
it('refuses an object field value rather than writing [object Object]', async () => {
264+
/**
265+
* An unencodable field is a permanent refusal, so it has to come back settled
266+
* like every other create failure. Encoding it inside the request builder
267+
* would surface the TypeError as a 500, which the tool runner then retries.
268+
*/
269+
it('refuses an object field value as a settled failure, not a retryable 500', async () => {
265270
const response = await POST(
266271
createMockRequest('POST', { ...baseBody, data: '{"nested":{"a":1}}' })
267272
)
268-
const data = (await response.json()) as { success: boolean; error?: string }
273+
const data = (await response.json()) as {
274+
success: boolean
275+
output: unknown
276+
error?: string
277+
}
269278

279+
expect(response.status).toBe(200)
270280
expect(data.success).toBe(false)
281+
expect(data.output).toEqual({ id: null, fields: {} })
271282
expect(data.error).toContain('has no encoding for')
272283
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
273284
})
285+
286+
it('refuses a field that reuses a reserved $ parameter name', async () => {
287+
const response = await POST(
288+
createMockRequest('POST', { ...baseBody, data: '{"$table":"other_table"}' })
289+
)
290+
const data = (await response.json()) as { success: boolean; error?: string }
291+
292+
expect(response.status).toBe(200)
293+
expect(data.success).toBe(false)
294+
expect(data.error).toContain('reserved')
295+
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
296+
})
274297
})
275298

276299
describe('field projection', () => {

apps/sim/app/api/tools/agiloft/create_record/route.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
2-
import { toError } from '@sim/utils/errors'
2+
import { getErrorMessage, toError } from '@sim/utils/errors'
3+
import { truncate } from '@sim/utils/string'
34
import { type NextRequest, NextResponse } from 'next/server'
45
import { agiloftCreateRecordContract } from '@/lib/api/contracts/tools/agiloft'
56
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
@@ -69,13 +70,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
6970
})
7071
}
7172

73+
/**
74+
* Encoded before the request is issued, not inside the request builder: an
75+
* unencodable field is a permanent refusal, and a TypeError thrown from the
76+
* builder would surface as a 500 the tool runner then retries.
77+
*/
78+
let requestBody: string
79+
try {
80+
requestBody = buildCreateRecordBody(params, fieldValues)
81+
} catch (error) {
82+
return NextResponse.json({
83+
success: false,
84+
output: { id: null, fields: {} },
85+
error: getErrorMessage(error, 'The data parameter contains a value Agiloft cannot encode'),
86+
})
87+
}
88+
7289
const result = await executeEwRequest<AgiloftRecordResponse>(
7390
params,
7491
(base) => ({
7592
url: buildCreateRecordUrl(base),
7693
method: 'POST',
7794
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
78-
body: buildCreateRecordBody(params, fieldValues),
95+
body: requestBody,
7996
}),
8097
async (response) => {
8198
const text = await response.text()
@@ -90,7 +107,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
90107
return {
91108
success: false,
92109
output: { id: null, fields: {} },
93-
error: `Agiloft error ${response.status}: ${describeAgiloftError(text)}`,
110+
error: `Agiloft error ${response.status}: ${describeAgiloftError(truncate(text, 300))}`,
94111
}
95112
}
96113

@@ -110,7 +127,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
110127
return {
111128
success: false,
112129
output: { id: null, fields: {} },
113-
error: `Agiloft accepted the create but returned no record ID, so the record may exist. Check the table before retrying - retrying creates a second record. Response: ${describeAgiloftError(text)}`,
130+
error: `Agiloft accepted the create but returned no record ID, so the record may exist. Check the table before retrying - retrying creates a second record. Response: ${describeAgiloftError(truncate(text, 300))}`,
114131
}
115132
}
116133

apps/sim/app/api/tools/agiloft/nlp_search/route.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,19 @@ describe('EWNLPSearch response', () => {
155155
)
156156

157157
const response = await POST(createMockRequest('POST', baseBody))
158-
const data = (await response.json()) as { success: boolean; error?: string }
158+
const data = (await response.json()) as {
159+
success: boolean
160+
output: unknown
161+
error?: string
162+
}
159163

164+
/**
165+
* A refusal Agiloft already settled must come back as a completed failure.
166+
* A 500 would have the tool runner retry a search it has already declined.
167+
*/
168+
expect(response.status).toBe(200)
160169
expect(data.success).toBe(false)
170+
expect(data.output).toEqual({ records: [], totalCount: 0, truncated: false })
161171
expect(data.error).toContain('One has to specify $login, $password')
162172
})
163173

apps/sim/app/api/tools/agiloft/nlp_search/route.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
buildNlpSearchBody,
1313
buildNlpSearchUrl,
1414
} from '@/tools/agiloft/utils'
15-
import { executeEwRequest, readAlrestJson } from '@/tools/agiloft/utils.server'
15+
import { executeEwRequest, isAgiloftRefusal, readAlrestJson } from '@/tools/agiloft/utils.server'
1616

1717
export const dynamic = 'force-dynamic'
1818

@@ -62,6 +62,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
6262
body: buildNlpSearchBody(params),
6363
}),
6464
async (response) => {
65+
/**
66+
* EWNLPSearch is the one legacy `EW*` operation that answers in the JSON
67+
* envelope the `alrest` surface uses — `{success, message, result}` with
68+
* `result` as the record array — rather than `EWREST_` assignments. Its
69+
* request half is form-encoded like the rest of the `EW*` surface, so
70+
* the two halves deliberately use different conventions. Do not
71+
* "correct" this to `parseEwRest` to match create.
72+
*/
6573
const returned = (await readAlrestJson<Record<string, unknown>[]>(response)) ?? []
6674
const records = returned.slice(0, AGILOFT_MAX_SEARCH_RECORDS)
6775

@@ -84,6 +92,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
8492

8593
return NextResponse.json(result)
8694
} catch (error) {
95+
/**
96+
* A refusal Agiloft already decided on is a final answer, not a transient
97+
* fault, so it is reported in a 200 body like every sibling operation does.
98+
* A 500 would have the tool runner retry a search Agiloft has already
99+
* declined.
100+
*/
101+
if (isAgiloftRefusal(error)) {
102+
logger.warn(`[${requestId}] Agiloft refused the request`, { error: error.message })
103+
return NextResponse.json({
104+
success: false,
105+
output: { records: [], totalCount: 0, truncated: false },
106+
error: error.message,
107+
})
108+
}
109+
87110
logger.error(`[${requestId}] Error running Agiloft NLP search:`, error)
88111

89112
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })

apps/sim/tools/agiloft/utils.server.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -277,8 +277,11 @@ export async function executeAlrestRequest<R extends ToolResponse>(
277277

278278
/**
279279
* Runs a single `/ewws/EW*` call. No login round-trip: that surface rejects the
280-
* bearer token and authenticates from the inline `$login`/`$password` already
281-
* present in the URL built by the caller.
280+
* bearer token and authenticates from the inline `$login`/`$password` the
281+
* caller puts on the request — in the query string for the operations that only
282+
* accept parameters there, in the form-encoded body for the ones that take it
283+
* (EWCreate, EWUpsert, EWNLPSearch), which keeps the password out of URLs and
284+
* access logs.
282285
*/
283286
export async function executeEwRequest<R extends ToolResponse>(
284287
params: AgiloftCredentials,

apps/sim/tools/agiloft/utils.test.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { describe, expect, it } from 'vitest'
55
import {
66
agiloftAlrestBase,
77
alrestDeleteRecordUrl,
8-
alrestRecordCollectionUrl,
98
alrestRecordUrl,
109
alrestSearchUrl,
1110
buildAttachFileUrl,
@@ -52,7 +51,6 @@ describe('agiloftAlrestBase', () => {
5251

5352
describe('alrest record routes', () => {
5453
it('builds collection, item, and search paths under the KB base', () => {
55-
expect(alrestRecordCollectionUrl(BASE, 'contract')).toBe(`${BASE}/contract?lang=en`)
5654
expect(alrestRecordUrl(BASE, 'contract', ' 6342 ')).toBe(`${BASE}/contract/6342?lang=en`)
5755
expect(alrestSearchUrl(BASE, 'contract')).toBe(`${BASE}/contract/search?lang=en`)
5856
})
@@ -344,3 +342,47 @@ describe('multi-value field encoding', () => {
344342
expect(sent.getAll('contactMethod')).toEqual(['phone', '42', 'true'])
345343
})
346344
})
345+
346+
describe('reserved parameter namespace', () => {
347+
/**
348+
* Record data reaches the body builders from workflow input, so a field named
349+
* after a reserved parameter would append a second occurrence of it and let
350+
* that data choose the table or the credentials.
351+
*/
352+
it('refuses a field that reuses a reserved $ parameter name', () => {
353+
expect(() => buildCreateRecordBody(baseParams, { $table: 'other_table' })).toThrow(TypeError)
354+
expect(() => buildCreateRecordBody(baseParams, { $password: 'evil' })).toThrow(TypeError)
355+
expect(() => buildUpsertRecordBody({ ...baseParams, match: 'id' }, { $KB: 'other' })).toThrow(
356+
TypeError
357+
)
358+
})
359+
360+
it('leaves the reserved pairs single-valued for ordinary field data', () => {
361+
const sent = new URLSearchParams(buildCreateRecordBody(baseParams, { contract_title1: 'X' }))
362+
expect(sent.getAll('$table')).toEqual(['contract'])
363+
expect(sent.getAll('$password')).toEqual([PLACEHOLDER_PASSWORD])
364+
})
365+
})
366+
367+
describe('pagination input', () => {
368+
const nlpBase = {
369+
instanceUrl: INSTANCE,
370+
knowledgeBase: baseParams.knowledgeBase,
371+
login: baseParams.login,
372+
password: PLACEHOLDER_PASSWORD,
373+
nlpQuery: 'Active NDAs',
374+
fields: 'id',
375+
}
376+
377+
it('drops a non-numeric or negative value rather than widening the search', () => {
378+
const sent = new URLSearchParams(buildNlpSearchBody({ ...nlpBase, page: 'abc', limit: '-1' }))
379+
expect(sent.has('page')).toBe(false)
380+
expect(sent.has('limit')).toBe(false)
381+
})
382+
383+
it('keeps a zero page, which is the documented first page', () => {
384+
const sent = new URLSearchParams(buildNlpSearchBody({ ...nlpBase, page: '0', limit: '10' }))
385+
expect(sent.get('page')).toBe('0')
386+
expect(sent.get('limit')).toBe('10')
387+
})
388+
})

apps/sim/tools/agiloft/utils.ts

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,6 @@ function tableSegment(table: string): string {
6060
return encodeURIComponent(table.trim())
6161
}
6262

63-
export function alrestRecordCollectionUrl(base: string, table: string): string {
64-
return `${base}/${tableSegment(table)}?lang=${AGILOFT_LANG}`
65-
}
66-
6763
export function alrestRecordUrl(base: string, table: string, recordId: string): string {
6864
return `${base}/${tableSegment(table)}/${encodeURIComponent(recordId.trim())}?lang=${AGILOFT_LANG}`
6965
}
@@ -232,13 +228,6 @@ export function encodeEwFormBody(fields: Array<[string, string]>): string {
232228
.join('&')
233229
}
234230

235-
/**
236-
* Expands caller-supplied record data into form pairs, appending to `fields`.
237-
*
238-
* Multi-value fields are encoded as repeated key/value pairs, not as a joined
239-
* string. Objects have no documented encoding at all, and String()-ing one
240-
* silently writes "[object Object]" into the record.
241-
*/
242231
/**
243232
* Renders one field value, refusing anything Agiloft has no encoding for.
244233
*
@@ -255,10 +244,30 @@ function encodeFieldValue(field: string, value: unknown): string {
255244
return String(value)
256245
}
257246

247+
/**
248+
* Expands caller-supplied record data into form pairs, appending to `fields`.
249+
*
250+
* Multi-value fields are encoded as repeated key/value pairs, not as a joined
251+
* string.
252+
*
253+
* `$` opens Agiloft's reserved request-parameter namespace — `$table`, `$KB`,
254+
* `$login`, `$password` — which this body has already set. Record data reaches
255+
* here from workflow input, so a field carrying one of those names would append
256+
* a second occurrence of a reserved parameter and let the caller's data decide
257+
* which table the record lands in, or which credentials the call runs under.
258+
* Whether the duplicate wins is Agiloft's parser's business, so the name is
259+
* refused rather than sent.
260+
*/
258261
function pushRecordFields(fields: Array<[string, string]>, data: Record<string, unknown>): void {
259262
for (const [field, value] of Object.entries(data)) {
260263
if (value === undefined || value === null) continue
261264

265+
if (field.startsWith('$')) {
266+
throw new TypeError(
267+
`Field "${field}" uses Agiloft's reserved "$" parameter prefix, which record data cannot set. Rename the field.`
268+
)
269+
}
270+
262271
if (Array.isArray(value)) {
263272
for (const entry of value) {
264273
if (entry === undefined || entry === null) continue
@@ -295,16 +304,17 @@ export function buildUpsertRecordBody(
295304
* EWCreate is the documented create operation. It answers with the ID of the
296305
* new record as an `EWREST_id` assignment, which is the only place that ID is
297306
* published. There is no documented JSON create that returns it.
298-
*
299-
* Every parameter travels in a form-encoded body: EWCreate lists
300-
* `application/x-www-form-urlencoded` as its supported Content-Type, it is one
301-
* of the operations that accept credentials in the body rather than the query
302-
* string, and a body has no request-line length ceiling on the record data.
303307
*/
304308
export function buildCreateRecordUrl(base: string): string {
305309
return `${base}/ewws/EWCreate`
306310
}
307311

312+
/**
313+
* Every parameter travels in a form-encoded body: EWCreate lists
314+
* `application/x-www-form-urlencoded` as its supported Content-Type, it is one
315+
* of the operations that accept credentials in the body rather than the query
316+
* string, and a body has no request-line length ceiling on the record data.
317+
*/
308318
export function buildCreateRecordBody(
309319
params: AgiloftBaseParams,
310320
data: Record<string, unknown>
@@ -442,6 +452,13 @@ export function buildNlpSearchUrl(base: string): string {
442452
* of the URL, access logs, and proxy traces. `field` repeats once per requested
443453
* field, matching Agiloft's multi-value encoding.
444454
*/
455+
/** Keeps a pagination input only when it reads as a non-negative whole number. */
456+
function toPaginationValue(value?: string): string | undefined {
457+
const trimmed = value?.trim()
458+
if (!trimmed) return undefined
459+
return /^\d+$/.test(trimmed) ? trimmed : undefined
460+
}
461+
445462
export function buildNlpSearchBody(params: AgiloftNlpSearchParams): string {
446463
const fields: Array<[string, string]> = [
447464
['$KB', params.knowledgeBase],
@@ -458,9 +475,16 @@ export function buildNlpSearchBody(params: AgiloftNlpSearchParams): string {
458475
/**
459476
* EWNLPSearch ignores the table and searches the whole knowledge base, so
460477
* pagination is the only bound a caller has on the result size.
478+
*
479+
* Both are documented as integers, and the block accepts them as free text,
480+
* so a non-numeric value is dropped rather than forwarded — Agiloft ignores
481+
* parameters it cannot read, which would silently widen the search instead of
482+
* bounding it.
461483
*/
462-
if (params.page) fields.push(['page', params.page])
463-
if (params.limit) fields.push(['limit', params.limit])
484+
const page = toPaginationValue(params.page)
485+
const limit = toPaginationValue(params.limit)
486+
if (page !== undefined) fields.push(['page', page])
487+
if (limit !== undefined) fields.push(['limit', limit])
464488

465489
return encodeEwFormBody(fields)
466490
}

0 commit comments

Comments
 (0)