Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions .changeset/agui-middleware-parity.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 33 additions & 2 deletions packages/cli/src/lib/dev/agui-handler.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -25,8 +28,17 @@ async function readBody(request: IncomingMessage): Promise<string> {
}

export async function handleAgUiRequest(options: AgUiRequestOptions): Promise<void> {
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
Expand Down Expand Up @@ -59,6 +71,24 @@ export async function handleAgUiRequest(options: AgUiRequestOptions): Promise<vo
return
}

// Run app middleware before starting the run — parity with runs/stream and
// runs/wait so auth/rate-limit/context middleware applies to /agui too.
const mwRequest: MiddlewareRequest = {
assistantId: route.assistantId,
headers: parseHeaders(request),
method: request.method ?? "POST",
params: extractRouteParams(route.routeId, input),
routeId: route.routeId,
url: request.url ?? `/agui/${routeKey}`,
}
const mwResult = await runMiddleware(middleware, mwRequest)
if (mwResult.action === "reject") {
response.statusCode = mwResult.status
response.setHeader("content-type", "application/json")
response.end(JSON.stringify(mwResult.body))
return
}

const threadId = input.threadId
const existing = await threadsStore.getThread(threadId)
if (!existing) await threadsStore.createThread({ thread_id: threadId })
Expand All @@ -78,6 +108,7 @@ export async function handleAgUiRequest(options: AgUiRequestOptions): Promise<vo
for await (const chunk of streamResolvedRoute({
appRoot,
input: dawnInput,
...(mwResult.context ? { middlewareContext: mwResult.context } : {}),
...(resumeDecision ? { resumeDecision } : {}),
routeFile: route.routeFile,
routeId: route.routeId,
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/lib/dev/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { IncomingMessage } from "node:http"
import type { DawnMiddleware, MiddlewareRequest, MiddlewareResult } from "@dawn-ai/sdk"

/**
Expand Down Expand Up @@ -41,3 +42,35 @@ export async function runMiddleware(

return await middleware(request)
}

/** Flatten an IncomingMessage's headers into a string map for MiddlewareRequest. */
export function parseHeaders(request: IncomingMessage): Record<string, string> {
const headers: Record<string, string> = {}
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<string, string> {
const params: Record<string, string> = {}
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
}
33 changes: 2 additions & 31 deletions packages/cli/src/lib/dev/runtime-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -373,6 +373,7 @@ function buildRouteTable(ctx: {
handle: async (req, res, params) => {
await handleAgUiRequest({
appRoot,
middleware,
registry,
threadsStore,
...(sandboxManager ? { sandboxManager } : {}),
Expand Down Expand Up @@ -1055,36 +1056,6 @@ async function listen(
})
}

function parseHeaders(request: IncomingMessage): Record<string, string> {
const headers: Record<string, string> = {}
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<string, string> {
const params: Record<string, string> = {}
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<string, unknown> {
return typeof value === "object" && value !== null
}
39 changes: 38 additions & 1 deletion packages/cli/test/agui-endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ afterEach(async () => {
for (const fn of cleanup.splice(0).reverse()) await fn()
})

async function fixtureApp(): Promise<string> {
async function fixtureApp(extraFiles: Record<string, string> = {}): Promise<string> {
const appRoot = await mkdtemp(join(tmpdir(), "dawn-agui-"))
cleanup.push(() => rm(appRoot, { force: true, recursive: true }))
const files: Record<string, string> = {
"dawn.config.ts": "export default {}\n",
"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)
Expand Down Expand Up @@ -76,3 +77,39 @@ it("streams AG-UI events from POST /agui/<route>", 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<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
cleanup.push(() => new Promise<void>((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)
Loading