Skip to content

Commit 2a042bd

Browse files
authored
Merge branch 'main' into feat/self-serve-schedules-addon
2 parents 4225f8d + 459dce2 commit 2a042bd

22 files changed

Lines changed: 697 additions & 24 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
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.

.github/workflows/publish.yml

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,13 +116,18 @@ jobs:
116116
image-ref: ${{ needs.publish-webapp.outputs.image_repo }}:${{ needs.publish-webapp.outputs.version }}
117117

118118
# Announce the freshly published mutable `main` webapp image to subscriber
119-
# repos in the org via repository_dispatch, handing them a digest-pinned ref to
120-
# build or deploy from. Fires only for the `main` tag — never semver releases or
121-
# other tag builds — and only from the canonical repo (forks have no PAT).
119+
# repos via repository_dispatch, handing them a digest-pinned ref to build or
120+
# deploy from. The repo, ref prefix, and dispatch target all default to the
121+
# canonical values and can be overridden by repository variables.
122+
#
123+
# `push` only: release builds reach publish.yml via workflow_call (from
124+
# release.yml) with an explicit image_tag while github.ref_name is still
125+
# `main`, so gate on the event to avoid dispatching — and failing on the
126+
# absent CROSS_REPO_PAT — during a release.
122127
dispatch-main-image:
123128
name: 📣 Dispatch main image
124129
needs: [publish-webapp]
125-
if: github.repository == 'triggerdotdev/trigger.dev' && needs.publish-webapp.outputs.version == 'main'
130+
if: github.repository == (vars.MAIN_IMAGE_DISPATCH_REPO || 'triggerdotdev/trigger.dev') && github.event_name == 'push' && startsWith(github.ref_name, vars.MAIN_IMAGE_DISPATCH_REF_PREFIX || 'main')
126131
runs-on: ubuntu-latest
127132
permissions: {}
128133
steps:
@@ -153,6 +158,6 @@ jobs:
153158
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
154159
with:
155160
token: ${{ secrets.CROSS_REPO_PAT }}
156-
repository: triggerdotdev/cloud
161+
repository: ${{ vars.MAIN_IMAGE_DISPATCH_TARGET || 'triggerdotdev/cloud' }}
157162
event-type: main-image-published
158163
client-payload: ${{ steps.payload.outputs.client_payload }}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Speed up the dashboard and API under high request load by memoizing react-router's per-request route matching, which previously re-flattened, re-ranked, and recompiled the entire route table on every request.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
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.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Stop `trigger()` from leaking raw database connection errors to API clients during a database outage; infrastructure errors now return a generic, retryable 500.

apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,27 @@ export const MessageBubble = memo(function MessageBubble({
7777
return null;
7878
});
7979

80+
// URLs in `source-url`/`file` parts come from streamed agent/tool data, so an
81+
// unsafe scheme like `javascript:` would become a clickable XSS payload once it
82+
// reaches an href/src. Allow only http(s)/blob (and data: for inline images),
83+
// and return null for anything else so the caller can skip the link/image.
84+
export function toSafeUrl(value: unknown, allowDataImage = false): string | null {
85+
if (typeof value !== "string") return null;
86+
let parsed: URL;
87+
try {
88+
parsed = new URL(value);
89+
} catch {
90+
return null;
91+
}
92+
if (parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.protocol === "blob:") {
93+
return value;
94+
}
95+
if (allowDataImage && parsed.protocol === "data:" && /^data:image\//i.test(value)) {
96+
return value;
97+
}
98+
return null;
99+
}
100+
80101
export function renderPart(part: UIMessage["parts"][number], i: number) {
81102
const p = part as any;
82103
const type = part.type as string;
@@ -159,15 +180,25 @@ export function renderPart(part: UIMessage["parts"][number], i: number) {
159180

160181
// Source URL — clickable citation link
161182
if (type === "source-url") {
183+
const safeUrl = toSafeUrl(p.url);
184+
const label = p.title || p.url;
185+
// Unsafe scheme: render the citation text without a clickable link.
186+
if (!safeUrl) {
187+
return label ? (
188+
<div key={i} className="text-xs text-text-dimmed">
189+
{label}
190+
</div>
191+
) : null;
192+
}
162193
return (
163194
<div key={i} className="text-xs">
164195
<a
165-
href={p.url}
196+
href={safeUrl}
166197
target="_blank"
167198
rel="noopener noreferrer"
168199
className="text-indigo-400 underline hover:text-indigo-300"
169200
>
170-
{p.title || p.url}
201+
{label}
171202
</a>
172203
</div>
173204
);
@@ -187,19 +218,37 @@ export function renderPart(part: UIMessage["parts"][number], i: number) {
187218
if (type === "file") {
188219
const isImage = typeof p.mediaType === "string" && p.mediaType.startsWith("image/");
189220
if (isImage) {
221+
const safeSrc = toSafeUrl(p.url, true); // allow data: URIs for inline images
222+
// Unsafe scheme: fall back to the filename, matching the non-image branch.
223+
if (!safeSrc) {
224+
return p.filename ? (
225+
<div key={i} className="text-xs text-text-dimmed">
226+
{p.filename}
227+
</div>
228+
) : null;
229+
}
190230
return (
191231
<img
192232
key={i}
193-
src={p.url}
233+
src={safeSrc}
194234
alt={p.filename ?? "file"}
195235
className="max-h-64 rounded border border-charcoal-650"
196236
/>
197237
);
198238
}
239+
const safeUrl = toSafeUrl(p.url);
240+
// Unsafe scheme: show the filename without a clickable download link.
241+
if (!safeUrl) {
242+
return p.filename ? (
243+
<div key={i} className="text-xs text-text-dimmed">
244+
{p.filename}
245+
</div>
246+
) : null;
247+
}
199248
return (
200249
<div key={i} className="text-xs">
201250
<a
202-
href={p.url}
251+
href={safeUrl}
203252
target="_blank"
204253
rel="noopener noreferrer"
205254
className="text-indigo-400 underline hover:text-indigo-300"
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { json } from "@remix-run/server-runtime";
2+
import { type GetProjectEnvironmentsResponseBody } from "@trigger.dev/core/v3";
3+
import { z } from "zod";
4+
import { $replica } from "~/db.server";
5+
import { findProjectByRef } from "~/models/project.server";
6+
import { createLoaderPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
7+
import { sortEnvironments } from "~/utils/environmentSort";
8+
9+
const ParamsSchema = z.object({
10+
projectRef: z.string(),
11+
});
12+
13+
export const loader = createLoaderPATApiRoute(
14+
{
15+
params: ParamsSchema,
16+
corsStrategy: "all",
17+
// Resolve projectRef → org so the PAT plugin can ground its role-floor
18+
// calculation. Membership is enforced by the plugin (`authenticatePat`
19+
// rejects users who aren't members of the target org) and again by
20+
// `findProjectByRef` below.
21+
context: async (params) => {
22+
const project = await $replica.project.findFirst({
23+
where: { externalRef: params.projectRef },
24+
select: { organizationId: true },
25+
});
26+
return project ? { organizationId: project.organizationId } : {};
27+
},
28+
authorization: { action: "read", resource: () => ({ type: "environments" }) },
29+
},
30+
async ({ params, authentication }) => {
31+
const project = await findProjectByRef(params.projectRef, authentication.userId);
32+
33+
if (!project) {
34+
return json({ error: "Project not found" }, { status: 404 });
35+
}
36+
37+
const environments = await $replica.runtimeEnvironment.findMany({
38+
where: {
39+
projectId: project.id,
40+
// Only base/parent environments. Branch children (preview branches)
41+
// are excluded — syncs target the parent and branches override elsewhere.
42+
parentEnvironmentId: null,
43+
archivedAt: null,
44+
OR: [
45+
{ type: { in: ["STAGING", "PRODUCTION", "PREVIEW"] } },
46+
// dev is per-user: only return the caller's own dev environment
47+
{ type: "DEVELOPMENT", orgMember: { userId: authentication.userId } },
48+
],
49+
},
50+
select: {
51+
id: true,
52+
slug: true,
53+
type: true,
54+
isBranchableEnvironment: true,
55+
branchName: true,
56+
paused: true,
57+
},
58+
});
59+
60+
const result: GetProjectEnvironmentsResponseBody = sortEnvironments(environments).map((env) => ({
61+
id: env.id,
62+
slug: env.slug,
63+
type: env.type,
64+
isBranchableEnvironment: env.isBranchableEnvironment,
65+
branchName: env.branchName,
66+
paused: env.paused,
67+
}));
68+
69+
return json(result);
70+
}
71+
);

apps/webapp/app/runEngine/concerns/queues.server.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { RunEngine } from "~/v3/runEngine.server";
1515
import { env } from "~/env.server";
1616
import { tryCatch } from "@trigger.dev/core/v3";
1717
import { ServiceValidationError } from "~/v3/services/common.server";
18+
import { isInfrastructureError } from "~/utils/prismaErrors";
1819
import { createCache, createLRUMemoryStore, DefaultStatefulContext, Namespace } from "@internal/cache";
1920
import { singleton } from "~/utils/singleton";
2021
import type { TaskMetadataCache, TaskMetadataEntry } from "~/services/taskMetadataCache.server";
@@ -394,6 +395,17 @@ export class DefaultQueueManager implements QueueManager {
394395
);
395396

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Prisma } from "@trigger.dev/database";
2+
3+
// Prisma connectivity / infrastructure error codes — engine- and
4+
// connection-level failures, not query- or validation-level ones. When the
5+
// database is unreachable, Prisma 6.x throws a PrismaClientKnownRequestError
6+
// carrying one of these codes (e.g. P1001 "Can't reach database server").
7+
const INFRASTRUCTURE_PRISMA_CODES = new Set([
8+
"P1001", // Can't reach database server
9+
"P1002", // Database server reached but timed out
10+
"P1008", // Operations timed out
11+
"P1017", // Server has closed the connection
12+
]);
13+
14+
/**
15+
* True when `error` is a Prisma infrastructure/connectivity failure (DB
16+
* unreachable, timed out, connection dropped) rather than a query- or
17+
* validation-level error.
18+
*
19+
* These errors carry internal infrastructure detail (e.g. the database
20+
* hostname) in their `.message`, so they must never be surfaced to API
21+
* clients — callers should let them propagate to the generic 5xx handler
22+
* (which both scrubs the message and is retryable by the SDK) instead of
23+
* folding `.message` into a client-facing error.
24+
*/
25+
export function isInfrastructureError(error: unknown): boolean {
26+
if (
27+
error instanceof Prisma.PrismaClientInitializationError ||
28+
error instanceof Prisma.PrismaClientRustPanicError ||
29+
error instanceof Prisma.PrismaClientUnknownRequestError
30+
) {
31+
return true;
32+
}
33+
34+
if (error instanceof Prisma.PrismaClientKnownRequestError) {
35+
return INFRASTRUCTURE_PRISMA_CODES.has(error.code);
36+
}
37+
38+
return false;
39+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, expect, it } from "vitest";
2+
import { toSafeUrl } from "~/components/runs/v3/agent/AgentMessageView";
3+
4+
describe("toSafeUrl", () => {
5+
it("allows http(s) and blob URLs", () => {
6+
expect(toSafeUrl("https://example.com/x")).toBe("https://example.com/x");
7+
expect(toSafeUrl("http://example.com/x")).toBe("http://example.com/x");
8+
expect(toSafeUrl("blob:https://example.com/uuid")).toBe("blob:https://example.com/uuid");
9+
});
10+
11+
it("rejects javascript: and other dangerous schemes", () => {
12+
expect(toSafeUrl("javascript:alert(1)")).toBeNull();
13+
expect(toSafeUrl("JavaScript:alert(1)")).toBeNull();
14+
expect(toSafeUrl("vbscript:msgbox(1)")).toBeNull();
15+
expect(toSafeUrl("file:///etc/passwd")).toBeNull();
16+
});
17+
18+
it("rejects data: URLs unless inline images are explicitly allowed", () => {
19+
const dataImage = "data:image/png;base64,iVBORw0KGgo=";
20+
expect(toSafeUrl(dataImage)).toBeNull();
21+
expect(toSafeUrl(dataImage, true)).toBe(dataImage);
22+
// Only image data is allowed, even in image context — never data:text/html.
23+
expect(toSafeUrl("data:text/html,<script>alert(1)</script>", true)).toBeNull();
24+
});
25+
26+
it("rejects relative URLs and non-string/malformed input", () => {
27+
expect(toSafeUrl("/relative/path")).toBeNull();
28+
expect(toSafeUrl("not a url")).toBeNull();
29+
expect(toSafeUrl(undefined)).toBeNull();
30+
expect(toSafeUrl(null)).toBeNull();
31+
expect(toSafeUrl(42)).toBeNull();
32+
});
33+
});

0 commit comments

Comments
 (0)