From fbf4a4d0c2199d78fe29de05ca95c62ee081633f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 13 Jul 2026 12:04:00 -0700 Subject: [PATCH] fix(cli): run app middleware for the /agui endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parity with runs/stream, runs/wait, and resume: the AG-UI endpoint now runs loadMiddleware/runMiddleware — a rejecting middleware blocks the run and a continue-with-context threads middlewareContext into it. Extracts the shared parseHeaders/extractRouteParams helpers into middleware.ts (avoids a circular import). Adds an integration test proving a rejecting middleware blocks /agui. Co-Authored-By: Claude Opus 4.8 --- .changeset/agui-middleware-parity.md | 9 +++++ packages/cli/src/lib/dev/agui-handler.ts | 35 +++++++++++++++++-- packages/cli/src/lib/dev/middleware.ts | 33 ++++++++++++++++++ packages/cli/src/lib/dev/runtime-server.ts | 33 ++---------------- packages/cli/test/agui-endpoint.test.ts | 39 +++++++++++++++++++++- 5 files changed, 115 insertions(+), 34 deletions(-) create mode 100644 .changeset/agui-middleware-parity.md diff --git a/.changeset/agui-middleware-parity.md b/.changeset/agui-middleware-parity.md new file mode 100644 index 00000000..c1a6d50d --- /dev/null +++ b/.changeset/agui-middleware-parity.md @@ -0,0 +1,9 @@ +--- +"@dawn-ai/cli": patch +--- + +Run app middleware for the `POST /agui/{routeId}` endpoint, matching +`runs/stream` / `runs/wait` / `resume`. A middleware that rejects now blocks an +AG-UI run (returning its status/body), and a middleware that returns `context` +has it threaded into the run — so auth, rate-limiting, and context injection +apply to AG-UI clients too, not just the Agent-Protocol endpoints. diff --git a/packages/cli/src/lib/dev/agui-handler.ts b/packages/cli/src/lib/dev/agui-handler.ts index 178e83ae..c3ad043f 100644 --- a/packages/cli/src/lib/dev/agui-handler.ts +++ b/packages/cli/src/lib/dev/agui-handler.ts @@ -1,13 +1,16 @@ import type { IncomingMessage, ServerResponse } from "node:http" import { EventType, RunAgentInputSchema } from "@ag-ui/core" import { createAgUiTranslator, encodeAgUiSse, mapRunInput } from "@dawn-ai/ag-ui" +import type { DawnMiddleware, MiddlewareRequest } from "@dawn-ai/sdk" import type { ThreadsStore } from "@dawn-ai/sqlite-storage" import { streamResolvedRoute } from "../runtime/execute-route.js" import type { SandboxManager } from "../runtime/sandbox-manager.js" +import { extractRouteParams, parseHeaders, runMiddleware } from "./middleware.js" import type { RuntimeRegistry } from "./runtime-registry.js" interface AgUiRequestOptions { readonly appRoot: string + readonly middleware: DawnMiddleware | undefined readonly registry: RuntimeRegistry readonly threadsStore: ThreadsStore readonly sandboxManager?: SandboxManager @@ -25,8 +28,17 @@ async function readBody(request: IncomingMessage): Promise { } export async function handleAgUiRequest(options: AgUiRequestOptions): Promise { - const { appRoot, registry, threadsStore, sandboxManager, signal, request, response, routeKey } = - options + const { + appRoot, + middleware, + registry, + threadsStore, + sandboxManager, + signal, + request, + response, + routeKey, + } = options const raw = await readBody(request) let parsedJson: unknown @@ -59,6 +71,24 @@ export async function handleAgUiRequest(options: AgUiRequestOptions): Promise { + const headers: Record = {} + for (const [key, value] of Object.entries(request.headers)) { + if (typeof value === "string") { + headers[key] = value + } else if (Array.isArray(value)) { + headers[key] = value.join(", ") + } + } + return headers +} + +/** Pull `[param]` route-param values out of the run input, for MiddlewareRequest.params. */ +export function extractRouteParams(routeId: string, input: unknown): Record { + const params: Record = {} + const matches = routeId.matchAll(/\[(\w+)\]/g) + const inputRecord = (typeof input === "object" && input !== null ? input : {}) as Record< + string, + unknown + > + + for (const match of matches) { + const name = match[1] + if (name && name in inputRecord) { + params[name] = String(inputRecord[name]) + } + } + + return params +} diff --git a/packages/cli/src/lib/dev/runtime-server.ts b/packages/cli/src/lib/dev/runtime-server.ts index c109ceb2..3d554f5f 100644 --- a/packages/cli/src/lib/dev/runtime-server.ts +++ b/packages/cli/src/lib/dev/runtime-server.ts @@ -20,7 +20,7 @@ import { handleMemoryListRequest, handleMemoryRejectRequest, } from "./memory-handler.js" -import { loadMiddleware, runMiddleware } from "./middleware.js" +import { extractRouteParams, loadMiddleware, parseHeaders, runMiddleware } from "./middleware.js" import { createRuntimeRegistry, type RuntimeRegistry } from "./runtime-registry.js" import { createExecutionErrorBody, createRequestErrorBody } from "./server-errors.js" @@ -373,6 +373,7 @@ function buildRouteTable(ctx: { handle: async (req, res, params) => { await handleAgUiRequest({ appRoot, + middleware, registry, threadsStore, ...(sandboxManager ? { sandboxManager } : {}), @@ -1055,36 +1056,6 @@ async function listen( }) } -function parseHeaders(request: IncomingMessage): Record { - const headers: Record = {} - for (const [key, value] of Object.entries(request.headers)) { - if (typeof value === "string") { - headers[key] = value - } else if (Array.isArray(value)) { - headers[key] = value.join(", ") - } - } - return headers -} - -function extractRouteParams(routeId: string, input: unknown): Record { - const params: Record = {} - const matches = routeId.matchAll(/\[(\w+)\]/g) - const inputRecord = (typeof input === "object" && input !== null ? input : {}) as Record< - string, - unknown - > - - for (const match of matches) { - const name = match[1] - if (name && name in inputRecord) { - params[name] = String(inputRecord[name]) - } - } - - return params -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null } diff --git a/packages/cli/test/agui-endpoint.test.ts b/packages/cli/test/agui-endpoint.test.ts index ec7f4d11..6eeaac73 100644 --- a/packages/cli/test/agui-endpoint.test.ts +++ b/packages/cli/test/agui-endpoint.test.ts @@ -14,7 +14,7 @@ afterEach(async () => { for (const fn of cleanup.splice(0).reverse()) await fn() }) -async function fixtureApp(): Promise { +async function fixtureApp(extraFiles: Record = {}): Promise { const appRoot = await mkdtemp(join(tmpdir(), "dawn-agui-")) cleanup.push(() => rm(appRoot, { force: true, recursive: true })) const files: Record = { @@ -22,6 +22,7 @@ async function fixtureApp(): Promise { "package.json": '{ "name": "agui-fixture", "type": "module" }\n', "src/app/chat/index.ts": 'import { agent } from "@dawn-ai/sdk"\nexport default agent({ model: "gpt-5-mini", systemPrompt: "You are helpful." })\n', + ...extraFiles, } for (const [rel, body] of Object.entries(files)) { const p = join(appRoot, rel) @@ -76,3 +77,39 @@ it("streams AG-UI events from POST /agui/", async () => { expect(text).toContain("Hi there!") expect(text).toContain('"type":"RUN_FINISHED"') }, 60_000) + +it("applies app middleware to /agui — a rejecting middleware blocks the run", async () => { + // Parity with runs/stream / runs/wait: /agui must run app middleware. A + // middleware that rejects should return its status/body and NOT start a run. + const appRoot = await fixtureApp({ + "src/middleware.ts": + "export default async () => ({ action: 'reject', status: 403, body: { error: 'blocked by middleware' } })\n", + }) + const { listener, close } = await createRuntimeRequestListener({ appRoot }) + cleanup.push(() => close()) + + const server: Server = createServer(listener) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + cleanup.push(() => new Promise((resolve) => server.close(() => resolve()))) + const { port } = server.address() as AddressInfo + + const routeKey = encodeURIComponent("/chat#agent") + const res = await fetch(`http://127.0.0.1:${port}/agui/${routeKey}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + threadId: "t-mw", + runId: "r-mw", + state: {}, + messages: [{ id: "1", role: "user", content: "hello" }], + tools: [], + context: [], + forwardedProps: {}, + }), + }) + const text = await res.text() + expect(res.status).toBe(403) + expect(JSON.parse(text)).toEqual({ error: "blocked by middleware" }) + // The run never started — no AG-UI stream was written. + expect(text).not.toContain("RUN_STARTED") +}, 60_000)