Skip to content

Commit 44feaaf

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
fix(sdk,webapp): validate chat.agent client messages
`chat.agent` now validates the messages it accepts from chat clients. Tool approvals and client-side tool outputs keep working, including after a run restarts. Mono-RevId: 2b23a74ed8d6c0df211dada502b2d70d1ec532ed
1 parent 4fecf9a commit 44feaaf

16 files changed

Lines changed: 897 additions & 139 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
`chat.agent` now accepts only two kinds of message from a chat client: user messages, and answers to tool calls the agent made. A tool approval or tool output is merged onto the stored call it answers and runs with the input from the agent's own history, so a client can't add tool calls or change a call's input. `chat.headStart` drops tool calls from the history the client sends, and `upsertIncomingMessage` only adds user messages.

‎apps/webapp/app/env.server.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,7 @@ const EnvironmentSchema = z
676676
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
677677
REALTIME_STREAMS_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
678678
REALTIME_STREAMS_INACTIVITY_TIMEOUT_MS: z.coerce.number().int().default(60000), // 1 minute
679+
REALTIME_SESSION_IN_HANDOVER_GUARD: z.enum(["enforce", "log"]).default("enforce"),
679680

680681
REALTIME_MAXIMUM_CREATED_AT_FILTER_AGE_IN_MS: z.coerce
681682
.number()

‎apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import {
55
canonicalSessionAddressingKey,
66
resolveSessionWithWriterFallback,
77
} from "~/services/realtime/sessions.server";
8-
import { stripClientIngressFields } from "~/services/realtime/sanitizeSessionInput.server";
8+
import { env } from "~/env.server";
9+
import {
10+
isServerOnlySessionInKind,
11+
sanitizeClientInPart,
12+
} from "~/services/realtime/sanitizeSessionInput.server";
913
import { isSafeSessionExternalId } from "~/services/realtime/sessionChannels.server";
1014
import { ensureRunThenAppend } from "~/services/realtime/sessionChannelAppend.server";
1115
import { anyResource, createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
@@ -105,7 +109,25 @@ const { action, loader } = createActionApiRoute(
105109

106110
let part = await request.text();
107111
if (params.io === "in") {
108-
part = stripClientIngressFields(part);
112+
const sanitized = sanitizeClientInPart(part);
113+
part = sanitized.part;
114+
if (isServerOnlySessionInKind(sanitized.kind) && authentication.type !== "PRIVATE") {
115+
const enforce = env.REALTIME_SESSION_IN_HANDOVER_GUARD === "enforce";
116+
logger.warn("Server-only record appended to session.in with a public access token", {
117+
sessionId: session.id,
118+
kind: sanitized.kind,
119+
enforced: enforce,
120+
});
121+
if (enforce) {
122+
return json(
123+
{
124+
ok: false,
125+
error: "Appending a handover to the in channel requires secret key authentication",
126+
},
127+
{ status: 403 }
128+
);
129+
}
130+
}
109131
}
110132

111133
// Ensure a live run, then append. A failed ensure is best effort: the

‎apps/webapp/app/services/realtime/sanitizeSessionInput.server.ts‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,38 @@
1313
* runtime rejects it as malformed.
1414
*/
1515
export function stripClientIngressFields(part: string): string {
16+
return sanitizeClientInPart(part).part;
17+
}
18+
19+
/**
20+
* {@link stripClientIngressFields}, plus the record's `kind` read from the same parse, so a route can
21+
* refuse kinds only a server may append (see {@link isServerOnlySessionInKind}).
22+
*/
23+
export function sanitizeClientInPart(part: string): { part: string; kind: string | undefined } {
1624
let record: unknown;
1725
try {
1826
record = JSON.parse(part);
1927
} catch {
20-
return part;
28+
return { part, kind: undefined };
2129
}
22-
if (!record || typeof record !== "object") return part;
30+
if (!record || typeof record !== "object") return { part, kind: undefined };
31+
32+
const rawKind = (record as { kind?: unknown }).kind;
33+
const kind = typeof rawKind === "string" ? rawKind : undefined;
2334

2435
const payload = (record as { payload?: unknown }).payload as Record<string, unknown> | undefined;
25-
if (!payload || typeof payload !== "object") return part;
36+
if (!payload || typeof payload !== "object") return { part, kind };
2637

27-
return removeClientIngressFields(payload) ? JSON.stringify(record) : part;
38+
return { part: removeClientIngressFields(payload) ? JSON.stringify(record) : part, kind };
39+
}
40+
41+
/**
42+
* `.in` record kinds only the customer's server sends: the head-start handover and its skip signal. The
43+
* agent takes the first one it receives as the warm first-turn response, tool calls included, so a
44+
* public access token must not be able to append one.
45+
*/
46+
export function isServerOnlySessionInKind(kind: string | undefined): boolean {
47+
return kind === "handover" || kind === "handover-skip";
2848
}
2949

3050
export function stripClientIngressFieldsFromBasePayload<T extends Record<string, unknown>>(

‎apps/webapp/test/sanitizeSessionInput.server.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { describe, expect, it } from "vitest";
22
import {
3+
isServerOnlySessionInKind,
4+
sanitizeClientInPart,
35
stripClientIngressFields,
46
stripClientIngressFieldsFromBasePayload,
57
} from "~/services/realtime/sanitizeSessionInput.server";
@@ -68,6 +70,20 @@ describe("stripClientIngressFields", () => {
6870
});
6971
});
7072

73+
describe("sanitizeClientInPart", () => {
74+
it("reads a handover kind whose property name is spelled with JSON escapes", () => {
75+
const forged = '{"\\u006bind":"handover","partialAssistantMessage":[],"isFinal":false}';
76+
const { kind } = sanitizeClientInPart(forged);
77+
expect(kind).toBe("handover");
78+
expect(isServerOnlySessionInKind(kind)).toBe(true);
79+
});
80+
81+
it("treats a message record as one a client may append", () => {
82+
const { kind } = sanitizeClientInPart(record({ trigger: "submit-message" }));
83+
expect(isServerOnlySessionInKind(kind)).toBe(false);
84+
});
85+
});
86+
7187
describe("stripClientIngressFieldsFromBasePayload", () => {
7288
it("removes channelEvent and a webhook actionSource and keeps everything else", () => {
7389
const basePayload = {

‎docs/ai-chat/backend.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -599,13 +599,13 @@ onAction: async ({ action }) => {
599599
},
600600

601601
// Side-effect once per net-new tool result when wire messages come in
602-
hydrateMessages: async ({ incomingMessages }) => {
602+
hydrateMessages: async ({ incomingMessages, previousMessages }) => {
603603
for (const msg of incomingMessages) {
604604
for (const r of chat.history.extractNewToolResults(msg)) {
605605
await onToolResolved({ id: r.toolCallId, output: r.output, errorText: r.errorText });
606606
}
607607
}
608-
return incomingMessages;
608+
return [...previousMessages, ...incomingMessages.filter((m) => m.role === "user")];
609609
},
610610
```
611611

‎docs/ai-chat/custom-agents.mdx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,12 @@ export const myChat = chat.customAgent({
423423
const incoming = currentPayload.message ? [currentPayload.message] : [];
424424
const turnInput =
425425
turn === 0 && continuationSeed.length > 0
426-
? [...continuationSeed.filter((s) => !incoming.some((m) => m.id === s.id)), ...incoming]
426+
? [
427+
...continuationSeed.filter(
428+
(s) => !incoming.some((m) => m.role === "user" && m.id === s.id)
429+
),
430+
...incoming,
431+
]
427432
: incoming;
428433
const messages = await conversation.addIncoming(turnInput, currentPayload.trigger, turn);
429434

@@ -488,6 +493,8 @@ export const myChat = chat.customAgent({
488493
- **Turn 0 or `trigger === "regenerate-message"`: replaces** the accumulator with exactly what you pass. This is why continuation seeding goes through `addIncoming` (above), and why a regenerate needs you to slice your own history — the wire omits the message on regenerate, so pass the stored history minus the last assistant message.
489494
- **Every other turn: appends** what you pass (the wire carries at most the one new user message).
490495

496+
In both modes, a tool approval or tool output from the client is merged onto the stored tool call it answers, found in the accumulator or earlier in what you pass, rather than appended as a new message. One that answers no pending call is dropped.
497+
491498
```ts
492499
const conversation = new chat.MessageAccumulator();
493500

‎docs/ai-chat/lifecycle-hooks.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,10 @@ export const myChat = chat.agent({
254254
On HITL continuations (`addToolOutput` / `addToolApproveResponse`) the assistant entry in `messages` is **slim** — `state` + `output` / `errorText` / `approval` only, no `input` or other parts. `validateUIMessages` against the AI SDK schema rejects that shape (the schema requires `input` on resolved tool parts), so filter to user messages first (or skip validation entirely on those turns). The example above does the filter.
255255
</Warning>
256256

257+
<Note>
258+
The agent only takes two kinds of message from a client: user messages, and resolutions of tool calls the agent itself made (an approval response for a call awaiting approval, or the output of a client-side tool). Tool parts on a user message, any client-supplied tool `input`, other roles, and resolutions that don't answer a pending call are dropped before your hooks run. Tool `input` always comes from the agent's own history.
259+
</Note>
260+
257261
<Note>
258262
`onValidateMessages` fires **before** `onTurnStart` and message accumulation. If you need to validate messages loaded from a database, do the loading in `onChatStart` or `onPreload` and let `onValidateMessages` validate the full incoming set each turn.
259263
</Note>

‎docs/ai-chat/migrating-from-hydrate-messages.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ With `hydrateMessages`, your hook was the source of truth and the runtime wrote
178178
```ts lib/transcript-storage.ts
179179
loadContext: async ({ chatId, clientData }, { incomingMessages }) => {
180180
const branch = await db.activeBranch(chatId, clientData.userId);
181-
return [...branch, ...incomingMessages];
181+
return [...branch, ...incomingMessages.filter((m) => m.role === "user")];
182182
},
183183
```
184184

‎docs/ai-chat/transcript-storage.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,11 +194,13 @@ const storage: TranscriptStorage<{ userId: string }> = {
194194
save: (ctx, changeset) => rows.save(ctx, changeset),
195195
loadContext: async ({ chatId, clientData }, { incomingMessages }) => {
196196
const branch = await rows.activeBranch(chatId, clientData.userId);
197-
return [...branch, ...incomingMessages];
197+
return [...branch, ...incomingMessages.filter((m) => m.role === "user")];
198198
},
199199
};
200200
```
201201

202+
Append only the user's messages. A tool approval or tool output from the browser arrives in `incomingMessages` too, and the runtime merges it onto the stored call in what you return, so passing it through adds nothing and puts client input into the conversation as is.
203+
202204
`save` keeps receiving every change, and crash recovery keeps running. This is the replacement for the deprecated [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) hook; setting both `hydrateMessages` and `storage` on an agent is a startup error.
203205

204206
## Writing your own storage

0 commit comments

Comments
 (0)