Skip to content

Commit ad821ea

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
fix(webapp,core,cli): handle task ids that contain a slash
Task ids that contain a slash (for example `types/zod`, or an id that starts with a slash) now work across more of the product: - Switching environments keeps you on the current page instead of dropping you back to the list. - The test page for a webhook task whose id contains a slash opens correctly. - Triggering a task whose id cannot be represented in a URL (for example one containing an unpaired surrogate) now fails with a clear error naming the task id, instead of a cryptic URI error. The HTTP API reference also notes that a task id must be percent-encoded in the request path when it contains reserved characters such as a slash. Mono-RevId: e5209c0dabe872f3527221a5ac42112f2d05fc25
1 parent 7020646 commit ad821ea

10 files changed

Lines changed: 88 additions & 15 deletions

File tree

.changeset/task-id-url-encoding.md

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+
Triggering a task whose id cannot be represented in a URL (for example an id containing an unpaired surrogate) now fails with a clear error naming the task id, instead of a cryptic URI error.
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+
Switching environments now keeps you on the current page when a task's id contains a slash, instead of dropping you back to the list. The test page for a webhook task whose id contains a slash also opens correctly now.

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
139139

140140
if (result.foundTask && result.triggerSource === "WEBHOOK") {
141141
throw redirect(
142-
`/orgs/${organizationSlug}/projects/${projectParam}/env/${envParam}/webhooks/${taskParam}?tab=console`
142+
`/orgs/${organizationSlug}/projects/${projectParam}/env/${envParam}/webhooks/${encodeURIComponent(
143+
taskParam
144+
)}?tab=console`
143145
);
144146
}
145147

apps/webapp/app/utils/pageSwitching.test.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -468,14 +468,22 @@ describe("pages named after something the environment did not issue", () => {
468468
expect(organizationPortablePage("models/gpt-5")).toBe("models");
469469
});
470470

471-
it("keep nothing but a single plain name in that last segment", () => {
471+
it("carry a percent-encoded slash in the last segment verbatim", () => {
472+
expect(environmentPortablePage("test/tasks/types%2Fzod")).toBe("test/tasks/types%2Fzod");
473+
expect(environmentPortablePage("test/tasks/%2Fmy-task")).toBe("test/tasks/%2Fmy-task");
474+
expect(environmentPortablePage("models/my%2Fmodel")).toBe("models/my%2Fmodel");
475+
expect(environmentPortablePage("models/%2f%2fevil.example.com")).toBe(
476+
"models/%2f%2fevil.example.com"
477+
);
478+
});
479+
480+
it("drop an empty or traversal-shaped last segment to the list page", () => {
472481
expect(environmentPortablePage("tasks/standard/..%2f..%2flogin")).toBe("");
473482
expect(environmentPortablePage("tasks/standard/../../login")).toBe("");
474483
expect(environmentPortablePage("agents/%2e%2e")).toBe("agents");
475484
expect(environmentPortablePage("agents/..")).toBe("agents");
476485
expect(environmentPortablePage("agents/%zz")).toBe("agents");
477486
expect(environmentPortablePage("agents/")).toBe("agents");
478-
expect(environmentPortablePage("models/my%2Fmodel")).toBe("models");
479487
expect(environmentPortablePage("prompts/my-prompt/extra")).toBe("prompts");
480488
});
481489
});
@@ -552,7 +560,6 @@ describe("a page suffix that is not a plain relative page", () => {
552560
"..%2fbranches",
553561
"tasks/standard/../../login",
554562
"agents/..%2f..%2flogin",
555-
"models/%2f%2fevil.example.com",
556563
"dashboards/custom/..%2f..%2flogin",
557564
];
558565

apps/webapp/app/utils/pageSwitching.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,12 @@ function nearestPage(suffix: string, pages: ReadonlySet<string>): string {
9191
}
9292

9393
/**
94-
* `suffix` itself when its last segment names the same thing in every environment, as long as that
95-
* segment is a single plain one — a traversal or an encoded path in its place falls through to the
96-
* list page above it.
94+
* `suffix` itself when its last segment names the same thing in every environment. The last segment
95+
* is carried through verbatim (still percent-encoded), so an id that decodes to one containing a `/`
96+
* (e.g. a task id like `types/zod`, or one that starts with a slash, encoded as `types%2Fzod` /
97+
* `%2Fmy-task`) keeps its page: the encoded slash stays inside the one segment and never becomes a
98+
* nested path. Only a decoded segment that is empty or shaped like a traversal (a `.` or `..` path
99+
* segment, or a backslash) falls through to the list page above it.
97100
*/
98101
function environmentNeutralPage(suffix: string): string | undefined {
99102
const boundary = suffix.lastIndexOf("/");
@@ -111,7 +114,10 @@ function environmentNeutralPage(suffix: string): string | undefined {
111114
return undefined;
112115
}
113116

114-
return slug !== "" && !/^\.+$/.test(slug) && !/[/\\]/.test(slug) ? suffix : undefined;
117+
if (slug === "" || slug.includes("\\")) return undefined;
118+
if (slug.split("/").some((part) => part === "." || part === "..")) return undefined;
119+
120+
return suffix;
115121
}
116122

117123
/** The page to keep when only the environment changes. */

docs/v3-openapi.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3912,7 +3912,10 @@ components:
39123912
required: true
39133913
schema:
39143914
type: string
3915-
description: The id of a task
3915+
description: |
3916+
The id of a task. If the id contains characters that are reserved in a URL path, such as a
3917+
slash (`/`), percent-encode it when building the request path (e.g. `types/zod` becomes
3918+
`types%2Fzod`).
39163919
example: my-task
39173920
runsFilterWithEnv:
39183921
in: query

packages/cli-v3/src/apiClient.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
ApiBranchListResponseBody,
4848
GenerateRegistryCredentialsResponseBody,
4949
RemoteBuildProviderStatusResponseBody,
50+
encodeTaskIdForPath,
5051
} from "@trigger.dev/core/v3";
5152
import {
5253
ReportViewModelSchema,
@@ -743,11 +744,15 @@ export class CliApiClient {
743744
throw new Error("triggerTaskRun: No access token");
744745
}
745746

746-
return wrapZodFetch(TriggerTaskResponse, `${this.apiURL}/api/v1/tasks/${taskId}/trigger`, {
747-
method: "POST",
748-
headers: this.getHeaders(),
749-
body: JSON.stringify(body ?? {}),
750-
});
747+
return wrapZodFetch(
748+
TriggerTaskResponse,
749+
`${this.apiURL}/api/v1/tasks/${encodeTaskIdForPath(taskId)}/trigger`,
750+
{
751+
method: "POST",
752+
headers: this.getHeaders(),
753+
body: JSON.stringify(body ?? {}),
754+
}
755+
);
751756
}
752757

753758
get dev() {
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { describe, expect, it } from "vitest";
2+
import { encodeTaskIdForPath } from "./encodeTaskIdForPath.js";
3+
4+
describe("encodeTaskIdForPath", () => {
5+
it("percent-encodes a plain id unchanged", () => {
6+
expect(encodeTaskIdForPath("my-task")).toBe("my-task");
7+
});
8+
9+
it("percent-encodes a slash so the id stays a single path segment", () => {
10+
expect(encodeTaskIdForPath("types/zod")).toBe("types%2Fzod");
11+
expect(encodeTaskIdForPath("/jobs/my-task")).toBe("%2Fjobs%2Fmy-task");
12+
});
13+
14+
it("throws a clear error for an id that cannot be encoded into a URL", () => {
15+
expect(() => encodeTaskIdForPath("bad\uD800id")).toThrowError(/Invalid task id/);
16+
});
17+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Percent-encode a task id for use as a single URL path segment. `encodeURIComponent` throws a
3+
* `URIError` for a string that cannot be represented in a URL (an unpaired UTF-16 surrogate);
4+
* rethrow it as a clear, actionable error naming the id instead of a cryptic "URI malformed".
5+
*/
6+
export function encodeTaskIdForPath(taskId: string): string {
7+
try {
8+
return encodeURIComponent(taskId);
9+
} catch (error) {
10+
if (error instanceof URIError) {
11+
throw new Error(
12+
`Invalid task id ${JSON.stringify(
13+
taskId
14+
)}: it contains characters that cannot be encoded into a URL (for example an unpaired surrogate). Rename the task to use valid characters.`
15+
);
16+
}
17+
18+
throw error;
19+
}
20+
}

packages/core/src/v3/apiClient/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ import {
109109
zodfetchCursorPage,
110110
zodfetchOffsetLimitPage,
111111
} from "./core.js";
112+
import { encodeTaskIdForPath } from "./encodeTaskIdForPath.js";
112113
import { ApiConnectionError, ApiError, BatchNotSealedError } from "./errors.js";
113114
import { refreshAccessTokenOnce, type RefreshAccessTokenFn } from "./refreshAccessToken.js";
114115
import {
@@ -207,6 +208,7 @@ export type {
207208
};
208209

209210
export * from "./getBranch.js";
211+
export { encodeTaskIdForPath } from "./encodeTaskIdForPath.js";
210212

211213
export type CreatePublicTokenRequestBody = {
212214
scopes: string[];
@@ -358,7 +360,7 @@ export class ApiClient {
358360
clientOptions?: ClientTriggerOptions,
359361
requestOptions?: TriggerRequestOptions
360362
) {
361-
const encodedTaskId = encodeURIComponent(taskId);
363+
const encodedTaskId = encodeTaskIdForPath(taskId);
362364

363365
return zodfetch(
364366
TriggerTaskResponse,

0 commit comments

Comments
 (0)