Merge branch 'main' into fix/webapp-sanitize-prisma-leaks

This commit is contained in:
Daniel Sutton
2026-05-21 11:14:05 +01:00
committed by GitHub
12 changed files with 178 additions and 65 deletions
@@ -0,0 +1,8 @@
---
area: webapp
type: fix
---
Pin chat.agent session snapshots to a single object store so writes and reads
always round-trip through the same provider when `OBJECT_STORE_DEFAULT_PROTOCOL`
is set.
@@ -0,0 +1,69 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { $replica } from "~/db.server";
import { chatSnapshotStoragePathForSession } from "~/services/realtime/chatSnapshot.server";
import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server";
import {
createActionApiRoute,
createLoaderApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
import { generatePresignedUrl } from "~/v3/objectStore.server";
const ParamsSchema = z.object({
sessionId: z.string(),
});
// `chatSnapshotStoragePath` is stamped on every new Session at row creation
// (see api.v1.sessions.ts). The fallback handles sessions created before
// the column existed — read against the currently-configured default
// protocol and compute the same path the SDK uploaded under.
function snapshotKey(session: { friendlyId: string; chatSnapshotStoragePath: string | null }) {
return session.chatSnapshotStoragePath ?? chatSnapshotStoragePathForSession(session.friendlyId);
}
const routeConfig = {
params: ParamsSchema,
allowJWT: true,
corsStrategy: "all" as const,
findResource: async (params: z.infer<typeof ParamsSchema>, auth: { environment: { id: string } }) =>
resolveSessionByIdOrExternalId($replica, auth.environment.id, params.sessionId),
};
export const { action } = createActionApiRoute(
{ ...routeConfig, method: "PUT" },
async ({ authentication, resource: session }) => {
if (!session) {
return json({ error: "Session not found" }, { status: 404 });
}
const signed = await generatePresignedUrl(
authentication.environment.project.externalRef,
authentication.environment.slug,
snapshotKey(session),
"PUT"
);
if (!signed.success) {
return json({ error: `Failed to generate presigned URL: ${signed.error}` }, { status: 500 });
}
return json({ presignedUrl: signed.url });
}
);
export const loader = createLoaderApiRoute(routeConfig, async ({ authentication, resource: session }) => {
if (!session) {
return json({ error: "Session not found" }, { status: 404 });
}
const signed = await generatePresignedUrl(
authentication.environment.project.externalRef,
authentication.environment.slug,
snapshotKey(session),
"GET"
);
if (!signed.success) {
return json({ error: `Failed to generate presigned URL: ${signed.error}` }, { status: 500 });
}
return json({ presignedUrl: signed.url });
});
@@ -17,6 +17,7 @@ import {
ensureRunForSession,
type SessionTriggerConfig,
} from "~/services/realtime/sessionRunManager.server";
import { chatSnapshotStoragePathForSession } from "~/services/realtime/chatSnapshot.server";
import { serializeSession } from "~/services/realtime/sessions.server";
import { SessionsRepository } from "~/services/sessionsRepository/sessionsRepository.server";
import {
@@ -181,6 +182,7 @@ const { action } = createActionApiRoute(
environmentType: authentication.environment.type,
organizationId: authentication.environment.organizationId,
streamBasinName: authentication.environment.organization.streamBasinName,
chatSnapshotStoragePath: chatSnapshotStoragePathForSession(friendlyId),
},
update: { triggerConfig: triggerConfigJson },
});
@@ -201,6 +203,7 @@ const { action } = createActionApiRoute(
environmentType: authentication.environment.type,
organizationId: authentication.environment.organizationId,
streamBasinName: authentication.environment.organization.streamBasinName,
chatSnapshotStoragePath: chatSnapshotStoragePathForSession(friendlyId),
},
});
}
@@ -70,6 +70,7 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
// customer-facing surface so customer rate limits shouldn't apply.
/^\/api\/v1\/packets\//,
/^\/api\/v2\/packets\//,
/^\/api\/v1\/sessions\/[^\/]+\/snapshot-url$/,
],
log: {
rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1",
@@ -0,0 +1,13 @@
import { env } from "~/env.server";
/**
* Canonical storage URI for a session's chat.agent snapshot. Stamped on
* `Session.chatSnapshotStoragePath` at row creation so PUT/GET presigns
* resolve to the same store even if `OBJECT_STORE_DEFAULT_PROTOCOL`
* changes later.
*/
export function chatSnapshotStoragePathForSession(friendlyId: string): string {
const path = `sessions/${friendlyId}/snapshot.json`;
const protocol = env.OBJECT_STORE_DEFAULT_PROTOCOL;
return protocol ? `${protocol}://${path}` : path;
}
@@ -26,6 +26,7 @@ import {
import type { UIMessage } from "ai";
import { afterEach, describe, expect, vi } from "vitest";
import { env } from "~/env.server";
import { chatSnapshotStoragePathForSession } from "~/services/realtime/chatSnapshot.server";
import { generatePresignedUrl } from "~/v3/objectStore.server";
vi.setConfig({ testTimeout: 60_000 });
@@ -54,22 +55,21 @@ function makeSnapshot(opts: { messages?: UIMessage[]; lastOutEventId?: string }
/**
* Stub `apiClientManager.clientOrThrow()` so the SDK helpers see a fake
* api client whose `getPayloadUrl` / `createUploadPayloadUrl` return
* presigned URLs minted by the webapp's real `generatePresignedUrl`
* (which signs against MinIO).
*
* The SDK helpers internally do `fetch(presignedUrl, ...)` to read/write
* the blob, so MinIO ends up holding the actual bytes.
* api client. Mirrors the snapshot-url route: derive the canonical
* `sessions/{id}/snapshot.json` key (with optional default-protocol
* prefix) and sign it via `generatePresignedUrl` against MinIO.
*/
function stubApiClient(opts: { projectRef: string; envSlug: string }) {
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({
async getPayloadUrl(filename: string) {
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, filename, "GET");
async getChatSnapshotUrl(sessionId: string) {
const key = chatSnapshotStoragePathForSession(sessionId);
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, key, "GET");
if (!result.success) throw new Error(result.error);
return { presignedUrl: result.url };
},
async createUploadPayloadUrl(filename: string) {
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, filename, "PUT");
async createChatSnapshotUploadUrl(sessionId: string) {
const key = chatSnapshotStoragePathForSession(sessionId);
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, key, "PUT");
if (!result.success) throw new Error(result.error);
return { presignedUrl: result.url };
},
+7 -4
View File
@@ -33,6 +33,7 @@ import {
import type { UIMessageChunk } from "ai";
import { afterEach, describe, expect, vi } from "vitest";
import { env } from "~/env.server";
import { chatSnapshotStoragePathForSession } from "~/services/realtime/chatSnapshot.server";
import { generatePresignedUrl } from "~/v3/objectStore.server";
vi.setConfig({ testTimeout: 60_000 });
@@ -77,13 +78,15 @@ function stubApiClient(opts: {
})
);
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({
async getPayloadUrl(filename: string) {
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, filename, "GET");
async getChatSnapshotUrl(sessionId: string) {
const key = chatSnapshotStoragePathForSession(sessionId);
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, key, "GET");
if (!result.success) throw new Error(result.error);
return { presignedUrl: result.url };
},
async createUploadPayloadUrl(filename: string) {
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, filename, "PUT");
async createChatSnapshotUploadUrl(sessionId: string) {
const key = chatSnapshotStoragePathForSession(sessionId);
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, key, "PUT");
if (!result.success) throw new Error(result.error);
return { presignedUrl: result.url };
},
@@ -0,0 +1 @@
ALTER TABLE "Session" ADD COLUMN IF NOT EXISTS "chatSnapshotStoragePath" TEXT;
@@ -830,6 +830,11 @@ model Session {
/// (OSS, or pre-backfill); reads fall back to the global basin.
streamBasinName String?
/// Storage URI (with protocol prefix) for this session's chat.agent
/// snapshot blob. Set on first snapshot write. Null = pre-column session,
/// fall back to computed default path.
chatSnapshotStoragePath String?
runs SessionRun[]
/// Idempotency: `(env, externalId)` uniquely identifies a session.
+26
View File
@@ -602,6 +602,32 @@ export class ApiClient {
);
}
/** Presigned PUT URL for a `chat.agent` session snapshot. */
createChatSnapshotUploadUrl(sessionId: string, requestOptions?: ZodFetchOptions) {
return zodfetch(
CreateUploadPayloadUrlResponseBody,
`${this.baseUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/snapshot-url`,
{
method: "PUT",
headers: this.#getHeaders(false),
},
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
);
}
/** Presigned GET URL for a `chat.agent` session snapshot. */
getChatSnapshotUrl(sessionId: string, requestOptions?: ZodFetchOptions) {
return zodfetch(
CreateUploadPayloadUrlResponseBody,
`${this.baseUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/snapshot-url`,
{
method: "GET",
headers: this.#getHeaders(false),
},
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
);
}
retrieveRun(runId: string, requestOptions?: ZodFetchOptions) {
return zodfetch(
RetrieveRunResponse,
+2 -16
View File
@@ -219,20 +219,6 @@ async function findLatestSessionInCursor(
*/
export type { ChatSnapshotV1 } from "@trigger.dev/core/v3";
/**
* S3 key suffix for a session's snapshot blob. The webapp's presigned-URL
* routes prefix this with `packets/{projectRef}/{envSlug}/` server-side, so
* the final S3 key lands at
* `packets/{projectRef}/{envSlug}/sessions/{sessionId}/snapshot.json`.
*
* Stable per session: the friendlyId persists across `chat.requestUpgrade`
* continuations and idle-suspend restarts.
* @internal
*/
function snapshotFilename(sessionId: string): string {
return `sessions/${sessionId}/snapshot.json`;
}
/**
* Test-only override hook `mockChatAgent` installs a fake to return
* synthetic snapshots without hitting S3. Mirrors the `__set*ImplForTests`
@@ -285,7 +271,7 @@ async function readChatSnapshot<TUIMessage extends UIMessage>(
const apiClient = apiClientManager.clientOrThrow();
let presignedUrl: string;
try {
const resp = await apiClient.getPayloadUrl(snapshotFilename(sessionId));
const resp = await apiClient.getChatSnapshotUrl(sessionId);
presignedUrl = resp.presignedUrl;
} catch (error) {
logger.warn("chat.agent: snapshot presign (read) failed; continuing without snapshot", {
@@ -360,7 +346,7 @@ async function writeChatSnapshot<TUIMessage extends UIMessage>(
const apiClient = apiClientManager.clientOrThrow();
let presignedUrl: string;
try {
const resp = await apiClient.createUploadPayloadUrl(snapshotFilename(sessionId));
const resp = await apiClient.createChatSnapshotUploadUrl(sessionId);
presignedUrl = resp.presignedUrl;
} catch (error) {
logger.warn("chat.agent: snapshot presign (write) failed; next run will replay further", {
+33 -35
View File
@@ -34,28 +34,27 @@ function buildSnapshot(count = 1): ChatSnapshotV1 {
/**
* Stub `apiClientManager.clientOrThrow()` so the helpers see a fake API
* client whose `getPayloadUrl` / `createUploadPayloadUrl` resolve with the
* presigned URLs the test wants. Returns spies for assertion.
* client whose `getChatSnapshotUrl` / `createChatSnapshotUploadUrl` resolve
* with the presigned URLs the test wants. Returns spies for assertion.
*/
function stubApiClient(opts: {
getPayloadUrl?: (filename: string) => Promise<{ presignedUrl: string }>;
createUploadPayloadUrl?: (filename: string) => Promise<{ presignedUrl: string }>;
getChatSnapshotUrl?: (sessionId: string) => Promise<{ presignedUrl: string }>;
createChatSnapshotUploadUrl?: (sessionId: string) => Promise<{ presignedUrl: string }>;
}) {
const getPayloadUrl = vi.fn(
opts.getPayloadUrl ?? (async (_filename: string) => ({ presignedUrl: "https://example.invalid/get" }))
const getChatSnapshotUrl = vi.fn(
opts.getChatSnapshotUrl ??
(async (_sessionId: string) => ({ presignedUrl: "https://example.invalid/get" }))
);
const createUploadPayloadUrl = vi.fn(
opts.createUploadPayloadUrl ??
(async (_filename: string) => ({ presignedUrl: "https://example.invalid/put" }))
const createChatSnapshotUploadUrl = vi.fn(
opts.createChatSnapshotUploadUrl ??
(async (_sessionId: string) => ({ presignedUrl: "https://example.invalid/put" }))
);
const fakeClient = {
getPayloadUrl,
createUploadPayloadUrl,
getChatSnapshotUrl,
createChatSnapshotUploadUrl,
};
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue(
fakeClient as never
);
return { getPayloadUrl, createUploadPayloadUrl };
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue(fakeClient as never);
return { getChatSnapshotUrl, createChatSnapshotUploadUrl };
}
/**
@@ -87,7 +86,7 @@ describe("chat snapshot helpers", () => {
describe("readChatSnapshot", () => {
it("returns the snapshot on a successful GET", async () => {
const { getPayloadUrl } = stubApiClient({});
const { getChatSnapshotUrl } = stubApiClient({});
const snapshot = buildSnapshot(2);
stubFetch(async () =>
new Response(JSON.stringify(snapshot), {
@@ -97,7 +96,7 @@ describe("chat snapshot helpers", () => {
);
const result = await readChatSnapshot("session-1");
expect(getPayloadUrl).toHaveBeenCalledWith("sessions/session-1/snapshot.json");
expect(getChatSnapshotUrl).toHaveBeenCalledWith("session-1");
expect(result).toMatchObject({
version: 1,
messages: snapshot.messages,
@@ -177,7 +176,7 @@ describe("chat snapshot helpers", () => {
it("returns undefined when presign call fails", async () => {
stubApiClient({
getPayloadUrl: async () => {
getChatSnapshotUrl: async () => {
throw new Error("presign denied");
},
});
@@ -202,13 +201,13 @@ describe("chat snapshot helpers", () => {
describe("writeChatSnapshot", () => {
it("PUTs the snapshot JSON to the presigned URL", async () => {
const { createUploadPayloadUrl } = stubApiClient({});
const { createChatSnapshotUploadUrl } = stubApiClient({});
const fetchSpy = stubFetch(async () => new Response(null, { status: 200 }));
const snapshot = buildSnapshot(3);
await writeChatSnapshot("session-2", snapshot);
expect(createUploadPayloadUrl).toHaveBeenCalledWith("sessions/session-2/snapshot.json");
expect(createChatSnapshotUploadUrl).toHaveBeenCalledWith("session-2");
expect(fetchSpy).toHaveBeenCalledOnce();
const [url, init] = fetchSpy.mock.calls[0]!;
expect(url).toBe("https://example.invalid/put");
@@ -239,7 +238,7 @@ describe("chat snapshot helpers", () => {
it("returns without throwing when presign fails (warns)", async () => {
stubApiClient({
createUploadPayloadUrl: async () => {
createChatSnapshotUploadUrl: async () => {
throw new Error("presign denied");
},
});
@@ -250,29 +249,28 @@ describe("chat snapshot helpers", () => {
expect(fetchSpy).not.toHaveBeenCalled();
});
it("uses the same `snapshotFilename(sessionId)` convention as the read path", async () => {
// Round-trip check: read and write target the same key for a given
// sessionId. The runtime relies on this to make read-after-write
// coherent on subsequent boots.
const { getPayloadUrl } = stubApiClient({
getPayloadUrl: async () => ({ presignedUrl: "https://example.invalid/get" }),
it("addresses reads and writes by the same sessionId", async () => {
// Round-trip check: both presign methods receive the same sessionId.
// The canonical key (`sessions/{id}/snapshot.json`) lives server-side
// now, so the SDK has no key string to compare — sessionId equality
// is the SDK-visible invariant.
const { getChatSnapshotUrl } = stubApiClient({
getChatSnapshotUrl: async () => ({ presignedUrl: "https://example.invalid/get" }),
});
stubFetch(async () => new Response(null, { status: 404 }));
// Trigger a read.
await readChatSnapshot("round-trip-session");
const [readKey] = getPayloadUrl.mock.calls[0]!;
const [readArg] = getChatSnapshotUrl.mock.calls[0]!;
// Trigger a write to the same session.
const { createUploadPayloadUrl } = stubApiClient({
createUploadPayloadUrl: async () => ({ presignedUrl: "https://example.invalid/put" }),
const { createChatSnapshotUploadUrl } = stubApiClient({
createChatSnapshotUploadUrl: async () => ({ presignedUrl: "https://example.invalid/put" }),
});
stubFetch(async () => new Response(null, { status: 200 }));
await writeChatSnapshot("round-trip-session", buildSnapshot());
const [writeKey] = createUploadPayloadUrl.mock.calls[0]!;
const [writeArg] = createChatSnapshotUploadUrl.mock.calls[0]!;
expect(readKey).toBe(writeKey);
expect(readKey).toBe("sessions/round-trip-session/snapshot.json");
expect(readArg).toBe(writeArg);
expect(readArg).toBe("round-trip-session");
});
});
});