diff --git a/packages/agent-cli/package.json b/packages/agent-cli/package.json index 72fbd9e24..1ecc6b937 100644 --- a/packages/agent-cli/package.json +++ b/packages/agent-cli/package.json @@ -37,5 +37,8 @@ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" + }, + "dependencies": { + "ajv": "^8.20.0" } } diff --git a/packages/agent-cli/src/commands/router.ts b/packages/agent-cli/src/commands/router.ts index b7f8067c4..8313386f5 100644 --- a/packages/agent-cli/src/commands/router.ts +++ b/packages/agent-cli/src/commands/router.ts @@ -558,6 +558,7 @@ interface ToolCatalogEntry { inputSchema: { type: 'object'; properties: Record; + required?: string[]; additionalProperties: boolean; }; buildCommand: (context: { @@ -568,6 +569,11 @@ interface ToolCatalogEntry { } interface InProcessToolEntry { + /** + * Same declaration the catalog exposes, so the in-process executor validates + * tool input exactly like the spawned-CLI executor does. + */ + inputSchema: ToolCatalogEntry['inputSchema']; execute: (context: { dataFile: string; input: Record; @@ -611,40 +617,6 @@ function appendToolSpecificOptions( return nextCommand; } -const toolInputSchema = { - type: 'object' as const, - properties: { - filter: { - type: ['string', 'array'], - description: - 'Optional field filter. Supports comma-separated paths like "items.id,items.name".', - }, - page: { - type: 'integer', - minimum: 1, - description: 'Optional page number for response pagination.', - }, - pageNumber: { - type: 'integer', - minimum: 1, - description: 'Alias for page.', - }, - pageSize: { - type: 'integer', - minimum: 1, - maximum: 1000, - description: 'Optional page size for response pagination.', - }, - limit: { - type: 'integer', - minimum: 1, - maximum: 1000, - description: 'Alias for pageSize and a bound for aggregate tool details.', - }, - } as Record, - additionalProperties: true, -}; - export function getToolCatalog(): ToolCatalogEntry[] { const tools: ToolCatalogEntry[] = []; for (const [group, subcommands] of Object.entries(SUBCOMMANDS)) { @@ -653,7 +625,7 @@ export function getToolCatalog(): ToolCatalogEntry[] { tools.push({ name: def.toolName, description: def.toolDescription ?? def.description, - inputSchema: toolInputSchema, + inputSchema: buildToolInputSchema(def.options), sourcePagination: getSourcePaginationConfig(def.options), buildCommand: ({ dataFile, input }) => appendToolSpecificOptions( @@ -696,7 +668,7 @@ export function describeRunSubcommands(): Array<{ toolName: def.toolName, path: `${group}.${subcommand}`, description: def.toolDescription ?? def.description, - args: toolInputSchema as Record, + args: buildToolInputSchema(def.options) as Record, }); } } @@ -713,6 +685,7 @@ export function getInProcessToolExecutors(): Record< for (const def of Object.values(subcommands)) { if (!def.toolName) continue; tools[def.toolName] = { + inputSchema: buildToolInputSchema(def.options), sourcePagination: getSourcePaginationConfig(def.options), execute: async ({ dataFile, input }) => { setDataFilePath(dataFile); @@ -757,6 +730,61 @@ function buildInputSchema(options: OptionDef[]): Record { return schema; } +function buildToolInputSchema( + options: OptionDef[], +): ToolCatalogEntry['inputSchema'] { + const properties: Record = { + filter: { + type: ['string', 'array'], + description: + 'Optional field filter. Supports comma-separated paths like "items.id,items.name".', + }, + page: { + type: 'integer', + minimum: 1, + description: 'Optional page number for response pagination.', + }, + pageNumber: { + type: 'integer', + minimum: 1, + description: 'Alias for page.', + }, + pageSize: { + type: 'integer', + minimum: 1, + maximum: 1000, + description: 'Optional page size for response pagination.', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 1000, + description: 'Alias for pageSize and a bound for aggregate tool details.', + }, + }; + const required: string[] = []; + + for (const option of options) { + const name = option.name.replace(/^--/, ''); + if (name === 'page-number' || name === 'page-size') { + continue; + } + if (properties[name] === undefined) { + properties[name] = optionToJsonSchema(option); + } + if (option.required) { + required.push(name); + } + } + + return { + type: 'object', + properties, + ...(required.length > 0 ? { required } : {}), + additionalProperties: false, + }; +} + export function describeCommandSchema(commandPath: string): unknown { const [group, subcommand] = commandPath.split('.'); if (!group || !subcommand) { diff --git a/packages/agent-cli/src/core/result-controls.ts b/packages/agent-cli/src/core/result-controls.ts index 62a2bc63c..bad8ae1b6 100644 --- a/packages/agent-cli/src/core/result-controls.ts +++ b/packages/agent-cli/src/core/result-controls.ts @@ -10,7 +10,20 @@ interface ParsedControls { paginateResult: boolean; } -const CONTROL_KEYS = new Set(['filter', 'page', 'pageNumber', 'pageSize']); +export const TOOL_INPUT_CONTROL_KEYS: ReadonlySet = new Set([ + 'filter', + 'limit', + 'page', + 'pageNumber', + 'pageSize', +]); + +const STRIPPED_TOOL_INPUT_CONTROL_KEYS = new Set([ + 'filter', + 'page', + 'pageNumber', + 'pageSize', +]); function parsePositiveInteger( value: unknown, @@ -236,7 +249,7 @@ export function splitToolInputControls( const passthroughInput: Record = {}; for (const [key, value] of Object.entries(input)) { - if (CONTROL_KEYS.has(key)) { + if (STRIPPED_TOOL_INPUT_CONTROL_KEYS.has(key)) { continue; } if ( diff --git a/packages/agent-cli/src/core/validate-input.ts b/packages/agent-cli/src/core/validate-input.ts new file mode 100644 index 000000000..055f97500 --- /dev/null +++ b/packages/agent-cli/src/core/validate-input.ts @@ -0,0 +1,195 @@ +import Ajv, { type ErrorObject, type ValidateFunction } from 'ajv'; + +import type { JsonSchema } from './types'; + +export interface ToolInputValidationIssue { + path: string; + message: string; +} + +export class ToolInputValidationError extends Error { + readonly toolName: string; + readonly issues: ToolInputValidationIssue[]; + + constructor(toolName: string, issues: ToolInputValidationIssue[]) { + super( + `Invalid input for rsdoctor tool ${toolName}: ${issues + .map((issue) => issue.message) + .join('; ')}`, + ); + this.name = 'ToolInputValidationError'; + this.toolName = toolName; + this.issues = issues; + } +} + +export interface ToolInputValidationOptions { + allowedAdditionalProperties?: ReadonlySet; +} + +const ajv = new Ajv({ + allErrors: true, + allowUnionTypes: true, + strict: false, +}); + +const validatorCache = new WeakMap< + JsonSchema, + Map | undefined, ValidateFunction> +>(); + +function buildValidationSchema( + schema: JsonSchema, + allowedAdditionalProperties?: ReadonlySet, +): JsonSchema { + if ( + schema.additionalProperties !== false || + allowedAdditionalProperties === undefined + ) { + return schema; + } + + const properties = { ...schema.properties }; + for (const property of allowedAdditionalProperties) { + properties[property] ??= {}; + } + + return { ...schema, properties }; +} + +function getValidator( + schema: JsonSchema, + allowedAdditionalProperties?: ReadonlySet, +): ValidateFunction { + let validators = validatorCache.get(schema); + if (validators === undefined) { + validators = new Map(); + validatorCache.set(schema, validators); + } + + let validate = validators.get(allowedAdditionalProperties); + if (validate === undefined) { + validate = ajv.compile( + buildValidationSchema(schema, allowedAdditionalProperties), + ); + validators.set(allowedAdditionalProperties, validate); + } + return validate; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function normalizeNumericStrings(value: unknown, schema: unknown): unknown { + if (!isRecord(schema)) return value; + + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + const acceptsString = types.includes('string'); + if (typeof value === 'string' && !acceptsString && value.trim() !== '') { + const parsed = Number(value); + if ( + Number.isFinite(parsed) && + (types.includes('number') || + (types.includes('integer') && Number.isInteger(parsed))) + ) { + return parsed; + } + } + + if (Array.isArray(value)) { + return value.map((item) => normalizeNumericStrings(item, schema.items)); + } + + if (!isRecord(value)) return value; + + const properties = isRecord(schema.properties) ? schema.properties : {}; + const additionalProperties = isRecord(schema.additionalProperties) + ? schema.additionalProperties + : undefined; + + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + normalizeNumericStrings(item, properties[key] ?? additionalProperties), + ]), + ); +} + +function decodePointerSegment(segment: string): string { + return segment.replaceAll('~1', '/').replaceAll('~0', '~'); +} + +function getPathSegments(instancePath: string): string[] { + return instancePath + .split('/') + .slice(1) + .map((segment) => decodePointerSegment(segment)); +} + +function formatPath(segments: string[]): string { + return segments.reduce( + (path, segment) => + /^\d+$/.test(segment) + ? `${path}[${segment}]` + : path === '' + ? segment + : `${path}.${segment}`, + '', + ); +} + +function describeLabel(path: string): string { + return path === '' ? 'input' : `"${path}"`; +} + +function formatIssue(error: ErrorObject): ToolInputValidationIssue { + const segments = getPathSegments(error.instancePath); + const basePath = formatPath(segments); + + if (error.keyword === 'required') { + return { + path: formatPath([...segments, String(error.params.missingProperty)]), + message: `${describeLabel(basePath)} ${error.message ?? 'is invalid'}`, + }; + } + + if (error.keyword === 'additionalProperties') { + const path = formatPath([ + ...segments, + String(error.params.additionalProperty), + ]); + return { + path, + message: `${describeLabel(path)} is not an allowed property`, + }; + } + + return { + path: basePath, + message: `${describeLabel(basePath)} ${error.message ?? 'is invalid'}`, + }; +} + +export function validateToolInput( + toolName: string, + input: unknown, + schema: JsonSchema | undefined, + options: ToolInputValidationOptions = {}, +): void { + if (schema === undefined) return; + + const validate = getValidator(schema, options.allowedAdditionalProperties); + if (validate(normalizeNumericStrings(input, schema))) return; + + throw new ToolInputValidationError( + toolName, + (validate.errors ?? []) + .toSorted( + (left, right) => + Number(left.keyword === 'additionalProperties') - + Number(right.keyword === 'additionalProperties'), + ) + .map(formatIssue), + ); +} diff --git a/packages/agent-cli/src/executor.ts b/packages/agent-cli/src/executor.ts index ddb5ce2f2..2b7abd319 100644 --- a/packages/agent-cli/src/executor.ts +++ b/packages/agent-cli/src/executor.ts @@ -9,7 +9,9 @@ import type { import { applyToolResultControls, splitToolInputControls, + TOOL_INPUT_CONTROL_KEYS, } from './core/result-controls'; +import { validateToolInput } from './core/validate-input'; import { getInProcessToolExecutors } from './commands'; import { loadJsonData } from './commands/datasource'; @@ -92,6 +94,9 @@ export function createRsdoctorCliToolExecutor({ return { async execute(request: ToolExecutionRequest): Promise { const tool = getToolByName(tools, request.toolName); + validateToolInput(request.toolName, request.input, tool.inputSchema, { + allowedAdditionalProperties: TOOL_INPUT_CONTROL_KEYS, + }); const { controls, passthroughInput, paginateResult } = splitToolInputControls(request.input, { sourcePagination: tool.sourcePagination, @@ -127,6 +132,9 @@ export function createInProcessRsdoctorCliToolExecutor(): ToolExecutor { if (!tool) { throw new Error(`Unknown rsdoctor tool: ${request.toolName}`); } + validateToolInput(request.toolName, request.input, tool.inputSchema, { + allowedAdditionalProperties: TOOL_INPUT_CONTROL_KEYS, + }); const { controls, passthroughInput, paginateResult } = splitToolInputControls(request.input, { sourcePagination: tool.sourcePagination, diff --git a/packages/agent-cli/src/index.ts b/packages/agent-cli/src/index.ts index 11d4e8478..84e1ee6e7 100644 --- a/packages/agent-cli/src/index.ts +++ b/packages/agent-cli/src/index.ts @@ -1,4 +1,5 @@ export * from './cli'; export * from './commands'; export * from './core/types'; +export * from './core/validate-input'; export * from './executor'; diff --git a/packages/agent-cli/tests/tool-input-validation.test.ts b/packages/agent-cli/tests/tool-input-validation.test.ts new file mode 100644 index 000000000..db8db0849 --- /dev/null +++ b/packages/agent-cli/tests/tool-input-validation.test.ts @@ -0,0 +1,504 @@ +import { describe, expect, it } from '@rstest/core'; + +import { getToolCatalog } from '../src/commands'; +import type { ToolDefinition } from '../src/core/types'; +import { ToolInputValidationError } from '../src/core/validate-input'; +import { + createInProcessRsdoctorCliToolExecutor, + createRsdoctorCliToolExecutor, +} from '../src/executor'; + +interface Harness { + execute: (input: unknown) => Promise; + commands: string[][]; +} + +function createHarness(inputSchema: ToolDefinition['inputSchema']): Harness { + const commands: string[][] = []; + const executor = createRsdoctorCliToolExecutor({ + tools: [ + { + name: 'schema_tool', + description: 'test tool', + inputSchema, + buildCommand: () => ['schema-tool'], + }, + ], + runCommand: async (command) => { + commands.push(command); + return JSON.stringify({ ok: true, data: { called: true } }); + }, + }); + + return { + commands, + execute: (input: unknown) => + executor.execute({ + toolName: 'schema_tool', + input: input as Record, + dataFile: '/tmp/demo.json', + }), + }; +} + +async function expectValidationError( + run: () => Promise, +): Promise { + let caught: unknown; + try { + await run(); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(ToolInputValidationError); + return caught as ToolInputValidationError; +} + +describe('tool input validation', () => { + it('passes valid input through unchanged', async () => { + const harness = createHarness({ + type: 'object', + properties: { + id: { type: 'string' }, + limit: { type: 'integer', minimum: 1, maximum: 10 }, + }, + required: ['id'], + additionalProperties: false, + }); + + const result = await harness.execute({ id: 'main', limit: 5 }); + + expect(result).toEqual({ ok: true, data: { called: true } }); + expect(harness.commands).toEqual([['schema-tool']]); + }); + + it('accepts an empty object for a tool without required fields', async () => { + const harness = createHarness({ + type: 'object', + properties: { id: { type: 'string' } }, + additionalProperties: false, + }); + + await expect(harness.execute({})).resolves.toEqual({ + ok: true, + data: { called: true }, + }); + }); + + it('rejects missing required properties before dispatch', async () => { + const harness = createHarness({ + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + additionalProperties: false, + }); + + const error = await expectValidationError(() => harness.execute({})); + + expect(error.toolName).toBe('schema_tool'); + expect(error.issues).toEqual([ + { path: 'id', message: "input must have required property 'id'" }, + ]); + expect(error.message).toBe( + "Invalid input for rsdoctor tool schema_tool: input must have required property 'id'", + ); + expect(harness.commands).toEqual([]); + }); + + it('rejects non-numeric strings for numeric types', async () => { + const harness = createHarness({ + type: 'object', + properties: { limit: { type: 'integer' } }, + additionalProperties: false, + }); + + const error = await expectValidationError(() => + harness.execute({ limit: 'ten' }), + ); + + expect(error.issues).toEqual([ + { + path: 'limit', + message: '"limit" must be integer', + }, + ]); + expect(harness.commands).toEqual([]); + }); + + it('accepts numeric strings and applies numeric bounds to them', async () => { + const harness = createHarness({ + type: 'object', + properties: { + count: { type: 'integer', minimum: 1, maximum: 10 }, + ratio: { type: 'number', minimum: 0, maximum: 1 }, + choice: { type: 'integer', enum: [1, 2] }, + }, + additionalProperties: false, + }); + + await expect( + harness.execute({ count: '2', ratio: '0.5', choice: '2' }), + ).resolves.toEqual({ ok: true, data: { called: true } }); + + const error = await expectValidationError(() => + harness.execute({ count: '11', ratio: '-0.1' }), + ); + expect(error.issues).toEqual([ + { path: 'count', message: '"count" must be <= 10' }, + { path: 'ratio', message: '"ratio" must be >= 0' }, + ]); + }); + + it('rejects values outside declared numeric bounds', async () => { + const harness = createHarness({ + type: 'object', + properties: { limit: { type: 'integer', minimum: 1, maximum: 10 } }, + additionalProperties: false, + }); + + const error = await expectValidationError(() => + harness.execute({ limit: 99 }), + ); + + expect(error.issues).toEqual([ + { path: 'limit', message: '"limit" must be <= 10' }, + ]); + }); + + it('rejects unknown enum values', async () => { + const harness = createHarness({ + type: 'object', + properties: { + category: { type: 'string', enum: ['cjs', 'barrel'] }, + }, + additionalProperties: false, + }); + + const error = await expectValidationError(() => + harness.execute({ category: 'esm' }), + ); + + expect(error.issues).toEqual([ + { + path: 'category', + message: '"category" must be equal to one of the allowed values', + }, + ]); + }); + + it('enforces standard JSON Schema string constraints', async () => { + const harness = createHarness({ + type: 'object', + properties: { + id: { type: 'string', minLength: 3, pattern: '^[a-z]+$' }, + }, + required: ['id'], + additionalProperties: false, + }); + + const error = await expectValidationError(() => + harness.execute({ id: 'A' }), + ); + + expect(error.issues).toEqual([ + { + path: 'id', + message: '"id" must NOT have fewer than 3 characters', + }, + { + path: 'id', + message: '"id" must match pattern "^[a-z]+$"', + }, + ]); + expect(harness.commands).toEqual([]); + }); + + it('compares structured enum values by JSON value', async () => { + const harness = createHarness({ + type: 'object', + properties: { + config: { type: 'object', enum: [{ mode: 'fast' }] }, + }, + additionalProperties: false, + }); + + await expect( + harness.execute({ config: { mode: 'fast' } }), + ).resolves.toEqual({ ok: true, data: { called: true } }); + }); + + it('preserves string enum values in numeric union types', async () => { + const harness = createHarness({ + type: 'object', + properties: { + choice: { type: ['string', 'integer'], enum: ['2', 3] }, + }, + additionalProperties: false, + }); + + await expect(harness.execute({ choice: '2' })).resolves.toEqual({ + ok: true, + data: { called: true }, + }); + }); + + it('rejects wrong array item types', async () => { + const harness = createHarness({ + type: 'object', + properties: { + fields: { type: 'array', items: { type: 'string' } }, + }, + additionalProperties: false, + }); + + const error = await expectValidationError(() => + harness.execute({ fields: ['id', 2] }), + ); + + expect(error.issues).toEqual([ + { + path: 'fields[1]', + message: '"fields[1]" must be string', + }, + ]); + }); + + it('rejects non-object input where an object is required', async () => { + const harness = createHarness({ + type: 'object', + properties: {}, + additionalProperties: true, + }); + + const undefinedInput = await expectValidationError(() => + harness.execute(undefined), + ); + expect(undefinedInput.issues).toEqual([ + { path: '', message: 'input must be object' }, + ]); + + const arrayInput = await expectValidationError(() => harness.execute([])); + expect(arrayInput.issues).toEqual([ + { path: '', message: 'input must be object' }, + ]); + + expect(harness.commands).toEqual([]); + }); + + it('rejects unknown properties only when the schema forbids them', async () => { + const strict = createHarness({ + type: 'object', + properties: {}, + additionalProperties: false, + }); + + const error = await expectValidationError(() => + strict.execute({ nope: 1 }), + ); + expect(error.issues).toEqual([ + { path: 'nope', message: '"nope" is not an allowed property' }, + ]); + + const open = createHarness({ + type: 'object', + properties: {}, + additionalProperties: true, + }); + await expect(open.execute({ nope: 1 })).resolves.toEqual({ + ok: true, + data: { called: true }, + }); + }); + + it('treats undefined required properties as missing', async () => { + const harness = createHarness({ + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + additionalProperties: false, + }); + + const error = await expectValidationError(() => + harness.execute({ id: undefined }), + ); + + expect(error.issues).toEqual([ + { path: 'id', message: "input must have required property 'id'" }, + ]); + expect(harness.commands).toEqual([]); + }); + + it('allows executor controls alongside a strict custom tool schema', async () => { + const harness = createHarness({ + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + additionalProperties: false, + }); + + await expect( + harness.execute({ id: 'main', filter: '', page: '1', pageSize: '2' }), + ).resolves.toEqual({ ok: true, data: { called: true } }); + expect(harness.commands).toEqual([['schema-tool']]); + }); + + it('reports every mismatch in a single error', async () => { + const harness = createHarness({ + type: 'object', + properties: { + id: { type: 'string' }, + limit: { type: 'integer', minimum: 1 }, + }, + required: ['id'], + additionalProperties: false, + }); + + const error = await expectValidationError(() => + harness.execute({ limit: 0, extra: true }), + ); + + expect(error.issues.map((issue) => issue.path)).toEqual([ + 'id', + 'limit', + 'extra', + ]); + }); + + it('validates catalog controls for the spawned cli executor', async () => { + const commands: string[][] = []; + const executor = createRsdoctorCliToolExecutor({ + tools: getToolCatalog(), + runCommand: async (command) => { + commands.push(command); + return JSON.stringify({ ok: true, data: {} }); + }, + }); + + const error = await expectValidationError(() => + executor.execute({ + toolName: 'chunks_list', + input: { page: 'two', pageSize: 5000 }, + dataFile: '/tmp/demo.json', + }), + ); + + expect(error.toolName).toBe('chunks_list'); + expect(error.issues).toEqual([ + { + path: 'page', + message: '"page" must be integer', + }, + { + path: 'pageSize', + message: '"pageSize" must be <= 1000', + }, + ]); + expect(commands).toEqual([]); + }); + + it('rejects unknown catalog properties instead of silently ignoring them', async () => { + const commands: string[][] = []; + const executor = createRsdoctorCliToolExecutor({ + tools: getToolCatalog(), + runCommand: async (command) => { + commands.push(command); + return JSON.stringify({ ok: true, data: {} }); + }, + }); + + const error = await expectValidationError(() => + executor.execute({ + toolName: 'chunks_list', + input: { page_number: 2 }, + dataFile: '/tmp/demo.json', + }), + ); + + expect(error.issues).toEqual([ + { + path: 'page_number', + message: '"page_number" is not an allowed property', + }, + ]); + expect(commands).toEqual([]); + }); + + it('catalog schemas retain declared tool options while rejecting extras', () => { + const catalog = getToolCatalog(); + const optimize = catalog.find((tool) => tool.name === 'bundle_optimize'); + const sideEffects = catalog.find( + (tool) => tool.name === 'tree_shaking_side_effects', + ); + + expect(optimize?.inputSchema).toMatchObject({ + properties: { + filter: expect.any(Object), + page: expect.any(Object), + pageSize: expect.any(Object), + step: { type: 'integer', enum: [1, 2] }, + }, + additionalProperties: false, + }); + expect(sideEffects?.inputSchema).toMatchObject({ + properties: { + category: { + type: 'string', + enum: ['cjs', 'barrel', 'side-effects', 'dynamic-import'], + }, + }, + additionalProperties: false, + }); + }); + + it('validates catalog controls for the in-process executor', async () => { + const executor = createInProcessRsdoctorCliToolExecutor(); + + const error = await expectValidationError(() => + executor.execute({ + toolName: 'build_summary', + input: { page: 'two' }, + dataFile: '/nonexistent/rsdoctor-data.json', + }), + ); + + expect(error.toolName).toBe('build_summary'); + expect(error.issues).toEqual([ + { + path: 'page', + message: '"page" must be integer', + }, + ]); + }); + + it('rejects unknown catalog properties in the in-process executor', async () => { + const executor = createInProcessRsdoctorCliToolExecutor(); + + const error = await expectValidationError(() => + executor.execute({ + toolName: 'build_summary', + input: { page_number: 2 }, + dataFile: '/nonexistent/rsdoctor-data.json', + }), + ); + + expect(error.issues).toEqual([ + { + path: 'page_number', + message: '"page_number" is not an allowed property', + }, + ]); + }); + + it('keeps rejecting unknown tools before validating input', async () => { + const executor = createInProcessRsdoctorCliToolExecutor(); + + await expect( + executor.execute({ + toolName: 'not_a_tool', + input: undefined as unknown as Record, + dataFile: '/tmp/demo.json', + }), + ).rejects.toThrow('Unknown rsdoctor tool: not_a_tool'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6dadc08d..7b8f1c21a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -451,6 +451,10 @@ importers: version: 24.12.3 packages/agent-cli: + dependencies: + ajv: + specifier: ^8.20.0 + version: 8.20.0 devDependencies: '@rslib/core': specifier: 1.0.0-rc.0