diff --git a/AGENTS.md b/AGENTS.md index a7f356c91..8eb04b094 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,6 +103,21 @@ box run-script start:lucee # Lucee CFML engine box run-script start:2023 # Adobe ColdFusion 2023 ``` +### Local Setup (BoxLang + CommandBox) + +Install BoxLang with the quick installer, which also gives you the `box` CommandBox launcher. Add BoxLang modules with `install-bx-module`: + +```bash +curl -fsSL https://install.boxlang.io | bash -s -- --yes +install-bx-module bx-ai # needed by the toAi()/toMCP() router specs +box install # TestBox and the formatter +``` + +- If `install-bx-module` reports "Helper scripts not found", run `/usr/local/boxlang/scripts/install-bx-module.sh ` directly. +- If `box install` fails with a PKIX or certificate error behind a TLS-intercepting proxy, import the proxy CA into CommandBox's own truststore (`~/.CommandBox/engine/cfml/cli/lucee-server/context/security/cacerts`). `JAVA_TOOL_OPTIONS` is not enough. +- Run the router specs without the full test app by serving a small runner with `boxlang-miniserver` that maps `/coldbox`, `/testbox` and `/tests`. Restart it after installing a module. +- CI fails on formatting that differs from `box run-script format:check`. Format only the files you changed and do not accept reflows of lines outside your diff. + ### Building & Formatting ```bash diff --git a/changelog.md b/changelog.md index 5f15075b6..dd288d944 100644 --- a/changelog.md +++ b/changelog.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `COLDBOX-1468` The `toAi()` routes (`/invoke`, `/stream`, `/batch`) answered HTTP 500 when the request had no body or the body was not a JSON object. They now answer 400 with `{ "error": true, "message": "The request body must be a JSON object" }` for a missing body, invalid JSON, a JSON array or a JSON string. - `COLDBOX-1466` Event caching ignored `noExecution()`. When an interceptor, route middleware or the request start handler denied a request (for example `renderData( statusCode = 429 ).noExecution()`), a warm cached event was still served with its cached 200, and a denied response for a cold event was cached and then served to allowed requests. Denied requests now skip event cache delivery and are never cached. - The development error page (`BugReport.cfm`) failed with an `encodeForHTML` error when an exception's `extendedInfo` was a struct, as BoxLang modules such as bx-orm set it, and hid the real error. `ExceptionBean.getExtendedInfo()` now always returns a string: complex values come back as JSON. diff --git a/system/web/routing/Router.cfc b/system/web/routing/Router.cfc index ccb09a1f6..c28218b78 100644 --- a/system/web/routing/Router.cfc +++ b/system/web/routing/Router.cfc @@ -2724,9 +2724,12 @@ component "verbs" : "POST", "response" : ( event, rc, prc ) => { var runnableInstance = isSimpleValue( capturedRunnable ) ? getInstance( capturedRunnable ) : capturedRunnable - var body = event.getHTTPContent( json: true ) - var aiContext = resolveAiContext( body ) - var options = body.options ?: {} + var body = resolveAiBody( event ) + if ( isNull( body ) ) { + return rejectAiBody( event ) + } + var aiContext = resolveAiContext( body ) + var options = body.options ?: {} options.append( aiContext, true ) var result = runnableInstance.run( body.input ?: {}, body.params ?: {}, options ) event.setHTTPHeader( name = "X-Thread-Id", value = aiContext.threadId ) @@ -2758,9 +2761,12 @@ component "verbs" : "POST", "response" : ( event, rc, prc ) => { var runnableInstance = isSimpleValue( capturedRunnable ) ? getInstance( capturedRunnable ) : capturedRunnable; - var body = event.getHTTPContent( json: true ); - var aiContext = resolveAiContext( body ); - var options = body.options ?: {}; + var body = resolveAiBody( event ); + if ( isNull( body ) ) { + return rejectAiBody( event ); + } + var aiContext = resolveAiContext( body ); + var options = body.options ?: {}; options.append( aiContext, true ); // Headers must go out before the stream opens @@ -2814,10 +2820,13 @@ component "verbs" : "POST", "response" : ( event, rc, prc ) => { var runnableInstance = isSimpleValue( capturedRunnable ) ? getInstance( capturedRunnable ) : capturedRunnable - var body = event.getHTTPContent( json: true ) - var params = body.params ?: {} - var aiContext = resolveAiContext( body ) - var options = body.options ?: {} + var body = resolveAiBody( event ) + if ( isNull( body ) ) { + return rejectAiBody( event ) + } + var params = body.params ?: {} + var aiContext = resolveAiContext( body ) + var options = body.options ?: {} options.append( aiContext, true ) var inputs = body.inputs ?: [] @@ -2900,6 +2909,40 @@ component return this; } + /** + * Read the JSON request body for a toAi() sub-route - shared by invoke/stream/batch. + * + * `getHTTPContent( json: true )` yields an empty string when there is no body or the JSON is + * invalid, and an array or string when the JSON is not an object. None of those can carry + * `input`, `params` or `options`, so they are all reported as "no usable body". + * + * @event The request context + * + * @return The parsed body struct, or null when the body is missing, invalid or not a JSON object + */ + private any function resolveAiBody( required event ){ + var body = arguments.event.getHTTPContent( json: true ) + return isStruct( body ) ? body : javacast( "null", "" ) + } + + /** + * Render the 400 answer for a toAi() request whose body is not a JSON object. + * + * @event The request context + */ + private string function rejectAiBody( required event ){ + var payload = { + "error" : true, + "message" : "The request body must be a JSON object" + } + arguments.event.renderData( + type = "json", + data = payload, + statusCode = 400 + ) + return "" + } + /** * Resolve the conversational identity/thread context for an AI request - shared by the * invoke/stream/batch sub-routes toAi() registers. diff --git a/tests/specs/web/routing/RouterAITest.cfc b/tests/specs/web/routing/RouterAITest.cfc index ed1aea443..23076138b 100644 --- a/tests/specs/web/routing/RouterAITest.cfc +++ b/tests/specs/web/routing/RouterAITest.cfc @@ -160,6 +160,78 @@ component extends="coldbox.system.testing.BaseModelTest" skip="notBoxlang" { } ) } ) + story( "I want toAi() invoke/stream/batch to reject a request body that is not a JSON object", function(){ + beforeEach( function(){ + variables.runnable = createStub().$( "run", "stub-output" ) + router.route( "/api/chat" ).toAi( runnable ) + } ) + + // Run a sub-route's response closure against a stub event whose body is `body` + function callRoute( required string path, required any body ){ + var route = router.getRoutes().filter( ( r ) => r.pattern == "api/chat/#path#/" )[ 1 ] + var event = createStub() + .$( "getHTTPContent", body ) + .$( "renderData" ) + .$( "setHTTPHeader" ) + return { event : event, result : route.response( event, {}, {} ) } + } + + function assertRejected( required struct called ){ + expect( called.event.$once( "renderData" ) ).toBeTrue() + var args = called.event.$callLog().renderData[ 1 ] + expect( args.statusCode ).toBe( 400 ) + expect( args.type ).toBe( "json" ) + expect( args.data.error ).toBeTrue() + expect( args.data.message ).toBe( "The request body must be a JSON object" ) + expect( runnable.$never( "run" ) ).toBeTrue() + } + + var routePaths = [ "invoke", "stream", "batch" ] + var badBodies = { + "no body" : "", + "invalid JSON" : "", + "a JSON array" : [ 1, 2 ], + "a JSON string" : "hello" + } + + routePaths.each( ( path ) => { + badBodies.each( ( label, badBody ) => { + given( "a POST to #path# with #label#", () => { + then( "it answers 400 with a JSON error", () => { + assertRejected( callRoute( path, badBody ) ) + } ) + } ) + } ) + } ) + + given( "a valid JSON object body on invoke", function(){ + then( "it runs the runnable and answers as before", function(){ + var called = callRoute( "invoke", { "input" : "hi", "userId" : "u1", "threadId" : "t1" } ) + expect( called.event.$never( "renderData" ) ).toBeTrue() + expect( called.result.success ).toBeTrue() + expect( called.result.output ).toBe( "stub-output" ) + expect( called.result.threadId ).toBe( "t1" ) + } ) + } ) + + given( "a valid JSON object body on batch", function(){ + then( "it runs the runnable for each input and answers as before", function(){ + var called = callRoute( + "batch", + { + "inputs" : [ "a", "b" ], + "userId" : "u1", + "threadId" : "t1" + } + ) + expect( called.event.$never( "renderData" ) ).toBeTrue() + expect( called.result.outputs ).toHaveLength( 2 ) + expect( called.result.outputs[ 1 ].success ).toBeTrue() + expect( called.result.threadId ).toBe( "t1" ) + } ) + } ) + } ) + story( "I want argument validation on toAi()", function(){ given( "a numeric value as runnable", function(){ then( "it should throw InvalidArgumentException", function(){