From e201fe2a3ae89b8f0c4986dda05a9ef024f23f0f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 16:26:56 +0000 Subject: [PATCH 1/4] COLDBOX-1468 Answer 400 when a toAi() body is not a JSON object The invoke, stream and batch routes passed the result of getHTTPContent( json: true ) straight to resolveAiContext( struct ). A missing body, invalid JSON, a JSON array or a JSON string made that a type error and the request ended as a 500. They now share one body check and answer 400 with { "error": true, "message": ... }. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01BJdN5wqon9WEbCykxdPcNN --- changelog.md | 1 + system/web/routing/Router.cfc | 59 ++++++++++++++++---- tests/specs/web/routing/RouterAITest.cfc | 68 ++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 10 deletions(-) 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..82729b4f3 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,36 @@ 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 ){ + arguments.event.renderData( + type = "json", + data = { "error" : true, "message" : "The request body must be a JSON object" }, + 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..bb4c46c21 100644 --- a/tests/specs/web/routing/RouterAITest.cfc +++ b/tests/specs/web/routing/RouterAITest.cfc @@ -160,6 +160,74 @@ 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(){ From c8c1807895cec59de636372f0beb6d9cc059779e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 16:30:28 +0000 Subject: [PATCH 2/4] COLDBOX-1468 Apply cfformat to Router and RouterAITest Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01BJdN5wqon9WEbCykxdPcNN --- system/web/routing/Router.cfc | 9 ++++++--- tests/specs/web/routing/RouterAITest.cfc | 22 +++++++++++++--------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/system/web/routing/Router.cfc b/system/web/routing/Router.cfc index 82729b4f3..fa01d5564 100644 --- a/system/web/routing/Router.cfc +++ b/system/web/routing/Router.cfc @@ -2932,8 +2932,11 @@ component */ private string function rejectAiBody( required event ){ arguments.event.renderData( - type = "json", - data = { "error" : true, "message" : "The request body must be a JSON object" }, + type = "json", + data = { + "error" : true, + "message" : "The request body must be a JSON object" + }, statusCode = 400 ) return "" @@ -3017,7 +3020,7 @@ component // Inline response closure: resolves the server name and delegates to MCPRequestProcessor var mcpResponseClosure = ( event, rc, prc ) => { - var resolvedServerName = rc.keyExists( "mcpServer" ) ? rc.mcpServer : serverName + var resolvedServerName = rc.keyExists( "mcpServer" ) ? rc.mcpServer : serverName return bxModules.bxai.models.mcp.MCPRequestProcessor::processHttp( resolvedServerName ); }; diff --git a/tests/specs/web/routing/RouterAITest.cfc b/tests/specs/web/routing/RouterAITest.cfc index bb4c46c21..23076138b 100644 --- a/tests/specs/web/routing/RouterAITest.cfc +++ b/tests/specs/web/routing/RouterAITest.cfc @@ -173,10 +173,7 @@ component extends="coldbox.system.testing.BaseModelTest" skip="notBoxlang" { .$( "getHTTPContent", body ) .$( "renderData" ) .$( "setHTTPHeader" ) - return { - event : event, - result : route.response( event, {}, {} ) - } + return { event : event, result : route.response( event, {}, {} ) } } function assertRejected( required struct called ){ @@ -191,10 +188,10 @@ component extends="coldbox.system.testing.BaseModelTest" skip="notBoxlang" { var routePaths = [ "invoke", "stream", "batch" ] var badBodies = { - "no body" : "", - "invalid JSON" : "", - "a JSON array" : [ 1, 2 ], - "a JSON string": "hello" + "no body" : "", + "invalid JSON" : "", + "a JSON array" : [ 1, 2 ], + "a JSON string" : "hello" } routePaths.each( ( path ) => { @@ -219,7 +216,14 @@ component extends="coldbox.system.testing.BaseModelTest" skip="notBoxlang" { 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" } ) + 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() From 15433cbcbc5373779f7b53a9d25132278cbb8157 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 16:33:46 +0000 Subject: [PATCH 3/4] COLDBOX-1468 Keep the existing resolvedServerName line as CI formats it The local cfformat reflowed a line outside this change, which the CI formatter rejects. Restore it and keep the 400 payload in a local variable like the other renderData calls in the router. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01BJdN5wqon9WEbCykxdPcNN --- system/web/routing/Router.cfc | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/system/web/routing/Router.cfc b/system/web/routing/Router.cfc index fa01d5564..c28218b78 100644 --- a/system/web/routing/Router.cfc +++ b/system/web/routing/Router.cfc @@ -2931,12 +2931,13 @@ component * @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 = { - "error" : true, - "message" : "The request body must be a JSON object" - }, + type = "json", + data = payload, statusCode = 400 ) return "" @@ -3020,7 +3021,7 @@ component // Inline response closure: resolves the server name and delegates to MCPRequestProcessor var mcpResponseClosure = ( event, rc, prc ) => { - var resolvedServerName = rc.keyExists( "mcpServer" ) ? rc.mcpServer : serverName + var resolvedServerName = rc.keyExists( "mcpServer" ) ? rc.mcpServer : serverName return bxModules.bxai.models.mcp.MCPRequestProcessor::processHttp( resolvedServerName ); }; From 19d49544ab65cf3ec85712e622b1f5f03a948c65 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 16:39:14 +0000 Subject: [PATCH 4/4] Document the local BoxLang and CommandBox setup in AGENTS.md Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01BJdN5wqon9WEbCykxdPcNN --- AGENTS.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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