Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
9373810
refactor(agiloft): add form-encoded body builders for the legacy EW* …
mzxchandra Aug 12, 2026
5f3a296
fix(agiloft): return the record ID instead of reporting a successful …
mzxchandra Aug 12, 2026
5d4790c
fix(agiloft): send natural language search credentials as request par…
mzxchandra Aug 12, 2026
e2d074b
docs(agiloft): document the field and table conventions
mzxchandra Aug 12, 2026
b324938
fix(agiloft): refuse an object nested in a multi-value field
mzxchandra Aug 12, 2026
2fe4b82
fix(agiloft): settle encoding refusals instead of returning a retryab…
mzxchandra Aug 12, 2026
38b6095
fix(agiloft): refuse redirects on credentialed calls and settle uncon…
mzxchandra Aug 13, 2026
f020433
fix(agiloft): redact credentials from relayed upstream response text
mzxchandra Aug 13, 2026
cd16012
fix(agiloft): redact encoded credentials and separate declines from u…
mzxchandra Aug 13, 2026
21c6ad5
fix(agiloft): redact credentials before the shared reader truncates t…
mzxchandra Aug 13, 2026
9e8b999
fix(agiloft): resolve the instance once so pre-send failures stay pre…
mzxchandra Aug 13, 2026
2fcaa35
fix(agiloft): parse the raw login body and keep the username out of logs
mzxchandra Aug 13, 2026
99ddf2f
chore(agiloft): regenerate the tool metadata for the create record ou…
mzxchandra Aug 13, 2026
a25b8dc
fix(agiloft): redact credentials on every error path the two routes r…
mzxchandra Aug 13, 2026
07970d0
fix(agiloft): make the redaction pipeline impossible to compose wrongly
mzxchandra Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix(agiloft): send natural language search credentials as request par…
…ameters

EWNLPSearch takes $KB, $login, and $password as request parameters. The
connector sent them as members of a JSON payload instead, so Agiloft refused
every call with "One has to specify $login, $password parameters" and the
operation never worked.

Send the whole request form-encoded, which the endpoint documents as a
supported Content-Type and which keeps the password out of the URL. The
field list repeats once per requested field, matching Agiloft's multi-value
encoding. Response handling is unchanged: the documented envelope already
matches what the route reads.

Also make Page and Limit reachable for this operation. Both were already in
the contract and the tool params, but their condition was pinned to Search
Records, leaving them with no UI field. This search ignores the table and
runs across the whole knowledge base, so pagination is the only bound a
caller has on the result size.

Drop the Limit output from natural language search: the response schema does
not return it and nothing populated it.
  • Loading branch information
mzxchandra committed Aug 13, 2026
commit 5d4790c565ccad6845b5e9abc283c51619c86188
183 changes: 183 additions & 0 deletions apps/sim/app/api/tools/agiloft/nlp_search/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/**
* @vitest-environment node
*/
import {
createMockRequest,
hybridAuthMockFns,
inputValidationMock,
inputValidationMockFns,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)

import { POST } from '@/app/api/tools/agiloft/nlp_search/route'

/** Obvious non-secret so credential scanners do not flag these fixtures. */
const PLACEHOLDER_PASSWORD = 'not-a-real-password'

const PINNED_IP = '93.184.216.34'

const baseBody = {
instanceUrl: 'https://example.agiloft.com',
knowledgeBase: 'Contract Templates',
login: 'svc.user',
password: PLACEHOLDER_PASSWORD,
nlpQuery: 'Active NDAs submitted last month',
fields: 'id, contract_title1',
}

function res(body: { ok?: boolean; status?: number; json?: unknown; text?: string }) {
const text = body.text ?? JSON.stringify(body.json ?? {})
return {
ok: body.ok ?? true,
status: body.status ?? 200,
statusText: '',
headers: new Headers(),
body: null,
text: async () => text,
json: async () => JSON.parse(text),
arrayBuffer: async () => new ArrayBuffer(0),
}
}

beforeEach(() => {
vi.clearAllMocks()
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
authType: 'internal_jwt',
})
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
isValid: true,
resolvedIP: PINNED_IP,
originalHostname: 'example.agiloft.com',
})
})

/** Envelope shape Agiloft documents for this endpoint. */
const RECORDS_OK = res({
json: {
success: true,
message: '',
result: [
{ id: 31, contract_title1: 'EXAMPLE_TITLE' },
{ id: 32, contract_title1: 'MASTER SERVICES AGREEMENT' },
],
},
})

describe('EWNLPSearch request', () => {
it('authenticates inline with a form body and no login round trip', async () => {
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(RECORDS_OK)

await POST(createMockRequest('POST', baseBody))

expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(1)

const [url, ip, init] = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[0]
expect(url).toBe('https://example.agiloft.com/ewws/EWNLPSearch')
expect(ip).toBe(PINNED_IP)
expect(init.method).toBe('POST')
expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded')
})

/**
* The credentials are request parameters. Sending them as members of a JSON
* payload is what makes Agiloft answer "One has to specify $login, $password
* parameters" and fail every call.
*/
it('sends the credentials as request parameters, not as payload keys', async () => {
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(RECORDS_OK)

await POST(createMockRequest('POST', baseBody))

const [url, , init] = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[0]
const sent = new URLSearchParams(init.body as string)

expect(sent.get('$KB')).toBe('Contract Templates')
expect(sent.get('$login')).toBe('svc.user')
expect(sent.get('$password')).toBe(PLACEHOLDER_PASSWORD)
expect(sent.get('$lang')).toBe('en')
expect(() => JSON.parse(init.body as string)).toThrow()
// The password must never reach the URL, where it would land in access logs.
expect(url).not.toContain(PLACEHOLDER_PASSWORD)
})

it('sends the query and repeats field once per requested field', async () => {
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(RECORDS_OK)

await POST(createMockRequest('POST', baseBody))

const [, , init] = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[0]
const sent = new URLSearchParams(init.body as string)

expect(sent.get('nlp_query')).toBe('Active NDAs submitted last month')
expect(sent.getAll('field')).toEqual(['id', 'contract_title1'])
})

it('forwards pagination, which is the only bound on a knowledge-base-wide search', async () => {
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(RECORDS_OK)

await POST(createMockRequest('POST', { ...baseBody, page: '2', limit: '25' }))

const [, , init] = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[0]
const sent = new URLSearchParams(init.body as string)

expect(sent.get('page')).toBe('2')
expect(sent.get('limit')).toBe('25')
})
})

describe('EWNLPSearch response', () => {
it('maps the documented envelope onto records and totalCount', async () => {
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(RECORDS_OK)

const response = await POST(createMockRequest('POST', baseBody))
const data = (await response.json()) as {
success: boolean
output: { records: unknown[]; totalCount: number; truncated: boolean }
}

expect(data.success).toBe(true)
expect(data.output.records).toHaveLength(2)
expect(data.output.totalCount).toBe(2)
expect(data.output.truncated).toBe(false)
})

it('surfaces the authentication refusal instead of reporting an empty result set', async () => {
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
res({
ok: false,
status: 400,
text: '<html><body>EWWrongDataException has occurred: One has to specify $login, $password parameters</body></html>',
})
)

const response = await POST(createMockRequest('POST', baseBody))
const data = (await response.json()) as { success: boolean; error?: string }

expect(data.success).toBe(false)
expect(data.error).toContain('One has to specify $login, $password')
})

it('caps the records it returns and reports the result as truncated', async () => {
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
res({
json: {
success: true,
result: Array.from({ length: 250 }, (_, index) => ({ id: index })),
},
})
)

const response = await POST(createMockRequest('POST', baseBody))
const data = (await response.json()) as {
output: { records: unknown[]; totalCount: number; truncated: boolean }
}

expect(data.output.records).toHaveLength(200)
expect(data.output.totalCount).toBe(200)
expect(data.output.truncated).toBe(true)
})
})
23 changes: 3 additions & 20 deletions apps/sim/app/api/tools/agiloft/nlp_search/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { filterUndefined } from '@sim/utils/object'
import { type NextRequest, NextResponse } from 'next/server'
import { agiloftNlpSearchContract } from '@/lib/api/contracts/tools/agiloft'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
Expand All @@ -9,10 +8,9 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { AgiloftNlpSearchResponse } from '@/tools/agiloft/types'
import {
AGILOFT_LANG,
AGILOFT_MAX_SEARCH_RECORDS,
buildNlpSearchBody,
buildNlpSearchUrl,
parseFieldList,
} from '@/tools/agiloft/utils'
import { executeEwRequest, readAlrestJson } from '@/tools/agiloft/utils.server'

Expand Down Expand Up @@ -60,23 +58,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
(base) => ({
url: buildNlpSearchUrl(base),
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
/**
* EWNLPSearch accepts application/json, so credentials travel in the
* body rather than the query string.
*/
body: JSON.stringify(
filterUndefined({
$KB: params.knowledgeBase,
$login: params.login,
$password: params.password,
$lang: AGILOFT_LANG,
field: parseFieldList(params.fields),
nlp_query: params.nlpQuery.trim(),
page: params.page ? Number(params.page) : undefined,
limit: params.limit ? Number(params.limit) : undefined,
})
),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: buildNlpSearchBody(params),
}),
async (response) => {
const returned = (await readAlrestJson<Record<string, unknown>[]>(response)) ?? []
Expand Down
12 changes: 9 additions & 3 deletions apps/sim/blocks/blocks/agiloft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const AgiloftBlock: BlockConfig = {
{ text: 'Search', field: 'knowledgeBase', core: true },
{ text: 'for', field: 'nlpQuery' },
{ text: ', returning', field: 'fields' },
{ text: ', up to', field: 'limit', after: 'records' },
],
select_records: [
{ text: 'Select record IDs from', field: 'table', core: true },
Expand Down Expand Up @@ -517,7 +518,7 @@ export const AgiloftBlock: BlockConfig = {
placeholder: '0',
description: 'Zero-based page number',
mode: 'advanced',
condition: { field: 'operation', value: 'search_records' },
condition: { field: 'operation', value: ['search_records', 'nlp_search'] },
},
{
id: 'limit',
Expand All @@ -526,7 +527,12 @@ export const AgiloftBlock: BlockConfig = {
placeholder: '25',
description: 'Records per page. 0 means every record, returned on page 0.',
mode: 'advanced',
condition: { field: 'operation', value: 'search_records' },
/**
* Natural Language Search has no table to narrow it: it runs across the
* whole knowledge base, so pagination is the caller's only bound on how
* much comes back.
*/
condition: { field: 'operation', value: ['search_records', 'nlp_search'] },
},
],

Expand Down Expand Up @@ -686,7 +692,7 @@ export const AgiloftBlock: BlockConfig = {
limit: {
type: 'number',
description: 'Page size that was requested; 0 when Agiloft chose the page size',
condition: { field: 'operation', value: ['search_records', 'nlp_search'] },
condition: { field: 'operation', value: 'search_records' },
},
truncated: {
type: 'boolean',
Expand Down