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
5 changes: 5 additions & 0 deletions .changeset/project-environments-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---

Add `GetProjectEnvironmentsResponseBody` and `ProjectEnvironment` schemas for the new `GET /api/v1/projects/{projectRef}/environments` endpoint, which lists the parent environments (dev, staging, preview, prod) a personal access token can access for a project. Dev is scoped to the token owner and branch (preview child) environments are excluded.
6 changes: 6 additions & 0 deletions .server-changes/sanitize-agent-view-urls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

Sanitize URLs from streamed agent and tool data before rendering them in the dashboard's Agent view, so an unsafe scheme such as `javascript:` can no longer produce a clickable link or image source.
6 changes: 6 additions & 0 deletions .server-changes/trigger-worker-queue-db-error-leak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

Stop `trigger()` from leaking raw database connection errors to API clients during a database outage; infrastructure errors now return a generic, retryable 500.
57 changes: 53 additions & 4 deletions apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,27 @@ export const MessageBubble = memo(function MessageBubble({
return null;
});

// URLs in `source-url`/`file` parts come from streamed agent/tool data, so an
// unsafe scheme like `javascript:` would become a clickable XSS payload once it
// reaches an href/src. Allow only http(s)/blob (and data: for inline images),
// and return null for anything else so the caller can skip the link/image.
export function toSafeUrl(value: unknown, allowDataImage = false): string | null {
if (typeof value !== "string") return null;
let parsed: URL;
try {
parsed = new URL(value);
} catch {
return null;
}
if (parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.protocol === "blob:") {
return value;
}
if (allowDataImage && parsed.protocol === "data:" && /^data:image\//i.test(value)) {
return value;
}
return null;
}

export function renderPart(part: UIMessage["parts"][number], i: number) {
const p = part as any;
const type = part.type as string;
Expand Down Expand Up @@ -159,15 +180,25 @@ export function renderPart(part: UIMessage["parts"][number], i: number) {

// Source URL — clickable citation link
if (type === "source-url") {
const safeUrl = toSafeUrl(p.url);
const label = p.title || p.url;
// Unsafe scheme: render the citation text without a clickable link.
if (!safeUrl) {
return label ? (
<div key={i} className="text-xs text-text-dimmed">
{label}
</div>
) : null;
}
return (
<div key={i} className="text-xs">
<a
href={p.url}
href={safeUrl}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-400 underline hover:text-indigo-300"
>
{p.title || p.url}
{label}
</a>
</div>
);
Expand All @@ -187,19 +218,37 @@ export function renderPart(part: UIMessage["parts"][number], i: number) {
if (type === "file") {
const isImage = typeof p.mediaType === "string" && p.mediaType.startsWith("image/");
if (isImage) {
const safeSrc = toSafeUrl(p.url, true); // allow data: URIs for inline images
// Unsafe scheme: fall back to the filename, matching the non-image branch.
if (!safeSrc) {
return p.filename ? (
<div key={i} className="text-xs text-text-dimmed">
{p.filename}
</div>
) : null;
}
return (
<img
key={i}
src={p.url}
src={safeSrc}
alt={p.filename ?? "file"}
className="max-h-64 rounded border border-charcoal-650"
/>
);
}
const safeUrl = toSafeUrl(p.url);
// Unsafe scheme: show the filename without a clickable download link.
if (!safeUrl) {
return p.filename ? (
<div key={i} className="text-xs text-text-dimmed">
{p.filename}
</div>
) : null;
}
return (
<div key={i} className="text-xs">
<a
href={p.url}
href={safeUrl}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-400 underline hover:text-indigo-300"
Expand Down
71 changes: 71 additions & 0 deletions apps/webapp/app/routes/api.v1.projects.$projectRef.environments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { json } from "@remix-run/server-runtime";
import { type GetProjectEnvironmentsResponseBody } from "@trigger.dev/core/v3";
import { z } from "zod";
import { $replica } from "~/db.server";
import { findProjectByRef } from "~/models/project.server";
import { createLoaderPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { sortEnvironments } from "~/utils/environmentSort";

const ParamsSchema = z.object({
projectRef: z.string(),
});

export const loader = createLoaderPATApiRoute(
{
params: ParamsSchema,
corsStrategy: "all",
// Resolve projectRef → org so the PAT plugin can ground its role-floor
// calculation. Membership is enforced by the plugin (`authenticatePat`
// rejects users who aren't members of the target org) and again by
// `findProjectByRef` below.
context: async (params) => {
const project = await $replica.project.findFirst({
where: { externalRef: params.projectRef },
select: { organizationId: true },
});
return project ? { organizationId: project.organizationId } : {};
},
authorization: { action: "read", resource: () => ({ type: "environments" }) },
},
async ({ params, authentication }) => {
const project = await findProjectByRef(params.projectRef, authentication.userId);

if (!project) {
return json({ error: "Project not found" }, { status: 404 });
}

const environments = await $replica.runtimeEnvironment.findMany({
where: {
projectId: project.id,
// Only base/parent environments. Branch children (preview branches)
// are excluded — syncs target the parent and branches override elsewhere.
parentEnvironmentId: null,
archivedAt: null,
OR: [
{ type: { in: ["STAGING", "PRODUCTION", "PREVIEW"] } },
// dev is per-user: only return the caller's own dev environment
{ type: "DEVELOPMENT", orgMember: { userId: authentication.userId } },
],
},
select: {
id: true,
slug: true,
type: true,
isBranchableEnvironment: true,
branchName: true,
paused: true,
},
});

const result: GetProjectEnvironmentsResponseBody = sortEnvironments(environments).map((env) => ({
id: env.id,
slug: env.slug,
type: env.type,
isBranchableEnvironment: env.isBranchableEnvironment,
branchName: env.branchName,
paused: env.paused,
}));

return json(result);
}
);
12 changes: 12 additions & 0 deletions apps/webapp/app/runEngine/concerns/queues.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { RunEngine } from "~/v3/runEngine.server";
import { env } from "~/env.server";
import { tryCatch } from "@trigger.dev/core/v3";
import { ServiceValidationError } from "~/v3/services/common.server";
import { isInfrastructureError } from "~/utils/prismaErrors";
import { createCache, createLRUMemoryStore, DefaultStatefulContext, Namespace } from "@internal/cache";
import { singleton } from "~/utils/singleton";
import type { TaskMetadataCache, TaskMetadataEntry } from "~/services/taskMetadataCache.server";
Expand Down Expand Up @@ -394,6 +395,17 @@ export class DefaultQueueManager implements QueueManager {
);

if (error) {
// getDefaultWorkerGroupForProject queries the writer DB. A Prisma
// infrastructure error (e.g. P1001 "Can't reach database server", whose
// message carries the DB hostname) must NOT be promoted into a
// client-facing ServiceValidationError: that leaks internal infra detail
// to the API client (the SDK echoes it into the run view) and
// mis-classifies a transient outage as a non-retryable 422. Let it
// propagate to the route's generic 500 handler (scrubbed + retryable);
// only wrap genuine domain failures.
if (isInfrastructureError(error)) {
throw error;
}
throw new ServiceValidationError(error.message);
}

Expand Down
39 changes: 39 additions & 0 deletions apps/webapp/app/utils/prismaErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Prisma } from "@trigger.dev/database";

// Prisma connectivity / infrastructure error codes — engine- and
// connection-level failures, not query- or validation-level ones. When the
// database is unreachable, Prisma 6.x throws a PrismaClientKnownRequestError
// carrying one of these codes (e.g. P1001 "Can't reach database server").
const INFRASTRUCTURE_PRISMA_CODES = new Set([
"P1001", // Can't reach database server
"P1002", // Database server reached but timed out
"P1008", // Operations timed out
"P1017", // Server has closed the connection
]);

/**
* True when `error` is a Prisma infrastructure/connectivity failure (DB
* unreachable, timed out, connection dropped) rather than a query- or
* validation-level error.
*
* These errors carry internal infrastructure detail (e.g. the database
* hostname) in their `.message`, so they must never be surfaced to API
* clients — callers should let them propagate to the generic 5xx handler
* (which both scrubs the message and is retryable by the SDK) instead of
* folding `.message` into a client-facing error.
*/
export function isInfrastructureError(error: unknown): boolean {
if (
error instanceof Prisma.PrismaClientInitializationError ||
error instanceof Prisma.PrismaClientRustPanicError ||
error instanceof Prisma.PrismaClientUnknownRequestError
) {
return true;
}

if (error instanceof Prisma.PrismaClientKnownRequestError) {
return INFRASTRUCTURE_PRISMA_CODES.has(error.code);
}

return false;
}
33 changes: 33 additions & 0 deletions apps/webapp/test/components/runs/v3/agent/AgentMessageView.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { toSafeUrl } from "~/components/runs/v3/agent/AgentMessageView";

describe("toSafeUrl", () => {
it("allows http(s) and blob URLs", () => {
expect(toSafeUrl("https://example.com/x")).toBe("https://example.com/x");
expect(toSafeUrl("http://example.com/x")).toBe("http://example.com/x");
expect(toSafeUrl("blob:https://example.com/uuid")).toBe("blob:https://example.com/uuid");
});

it("rejects javascript: and other dangerous schemes", () => {
expect(toSafeUrl("javascript:alert(1)")).toBeNull();
expect(toSafeUrl("JavaScript:alert(1)")).toBeNull();
expect(toSafeUrl("vbscript:msgbox(1)")).toBeNull();
expect(toSafeUrl("file:///etc/passwd")).toBeNull();
});

it("rejects data: URLs unless inline images are explicitly allowed", () => {
const dataImage = "data:image/png;base64,iVBORw0KGgo=";
expect(toSafeUrl(dataImage)).toBeNull();
expect(toSafeUrl(dataImage, true)).toBe(dataImage);
// Only image data is allowed, even in image context — never data:text/html.
expect(toSafeUrl("data:text/html,<script>alert(1)</script>", true)).toBeNull();
});

it("rejects relative URLs and non-string/malformed input", () => {
expect(toSafeUrl("/relative/path")).toBeNull();
expect(toSafeUrl("not a url")).toBeNull();
expect(toSafeUrl(undefined)).toBeNull();
expect(toSafeUrl(null)).toBeNull();
expect(toSafeUrl(42)).toBeNull();
});
});
32 changes: 32 additions & 0 deletions apps/webapp/test/prismaErrors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { Prisma } from "@trigger.dev/database";
import { isInfrastructureError } from "../app/utils/prismaErrors.js";

describe("isInfrastructureError", () => {
it("treats a P1001 'can't reach database server' (KnownRequestError) as infrastructure", () => {
// Prisma 6.x reports P1001 as a PrismaClientKnownRequestError with code P1001 —
// this is the exact production shape that leaked the RDS hostname to a customer.
const err = new Prisma.PrismaClientKnownRequestError(
"Invalid `prisma.project.findFirst()` invocation: Can't reach database server at host:5432",
{ code: "P1001", clientVersion: "6.14.0" }
);
expect(isInfrastructureError(err)).toBe(true);
});

it("treats a PrismaClientInitializationError as infrastructure", () => {
const err = new Prisma.PrismaClientInitializationError("init failed", "6.14.0");
expect(isInfrastructureError(err)).toBe(true);
});

it("does NOT treat a query/validation error (P2002 unique constraint) as infrastructure", () => {
const err = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "6.14.0",
});
expect(isInfrastructureError(err)).toBe(false);
});

it("does NOT treat a plain domain Error as infrastructure", () => {
expect(isInfrastructureError(new Error("Project not found."))).toBe(false);
});
});
17 changes: 17 additions & 0 deletions packages/core/src/v3/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,23 @@ export const GetProjectEnvResponse = z.object({

export type GetProjectEnvResponse = z.infer<typeof GetProjectEnvResponse>;

export const ProjectEnvironment = z.object({
id: z.string(),
/// The slug used as the environment identifier in env var endpoints (e.g. "dev", "stg", "prod", "preview")
slug: z.string(),
type: z.enum(["DEVELOPMENT", "STAGING", "PREVIEW", "PRODUCTION"]),
/// Whether this is the branchable parent (preview); individual branches are not returned
isBranchableEnvironment: z.boolean(),
branchName: z.string().nullable(),
paused: z.boolean(),
});

export type ProjectEnvironment = z.infer<typeof ProjectEnvironment>;

export const GetProjectEnvironmentsResponseBody = z.array(ProjectEnvironment);

export type GetProjectEnvironmentsResponseBody = z.infer<typeof GetProjectEnvironmentsResponseBody>;

// Zod schema for the response body type
export const GetWorkerTaskResponse = z.object({
id: z.string(),
Expand Down
Loading