From dc529414dfa29074ad7a7f3f06f16b4379e2311e Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:21:07 +0100 Subject: [PATCH 1/2] feat(webapp): add /_/* redirect route (#4523) --- .server-changes/deeplink-routes.md | 6 + apps/webapp/app/routes/[_].$.ts | 55 ++++ apps/webapp/app/utils/deeplinkPages.test.ts | 317 ++++++++++++++++++++ apps/webapp/app/utils/deeplinkPages.ts | 77 +++++ 4 files changed, 455 insertions(+) create mode 100644 .server-changes/deeplink-routes.md create mode 100644 apps/webapp/app/routes/[_].$.ts create mode 100644 apps/webapp/app/utils/deeplinkPages.test.ts create mode 100644 apps/webapp/app/utils/deeplinkPages.ts diff --git a/.server-changes/deeplink-routes.md b/.server-changes/deeplink-routes.md new file mode 100644 index 000000000..81367d655 --- /dev/null +++ b/.server-changes/deeplink-routes.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Short links like /_/apikeys now take you straight to that page in your current project and environment, so you no longer need the full URL with your org, project and environment in it. diff --git a/apps/webapp/app/routes/[_].$.ts b/apps/webapp/app/routes/[_].$.ts new file mode 100644 index 000000000..d5b0cedc8 --- /dev/null +++ b/apps/webapp/app/routes/[_].$.ts @@ -0,0 +1,55 @@ +import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { prisma } from "~/db.server"; +import { getUsersInvites } from "~/models/member.server"; +import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server"; +import { requireUser } from "~/services/session.server"; +import { deeplinkSuffix, resolveDeeplinkPage } from "~/utils/deeplinkPages"; +import { + invitesPath, + newOrganizationPath, + newProjectPath, + v3EnvironmentPath, +} from "~/utils/pathBuilder"; + +//`[_]` escapes the underscore: an unescaped `_.$` is a pathless layout, mounted at `/*`. +export const loader = async ({ request }: LoaderFunctionArgs) => { + const user = await requireUser(request); + + const { pathname, search } = new URL(request.url); + const page = resolveDeeplinkPage(deeplinkSuffix(pathname)); + + const invites = await getUsersInvites({ email: user.email }); + if (invites.length > 0) { + return redirect(invitesPath()); + } + + const presenter = new SelectBestEnvironmentPresenter(); + try { + const { project, organization, environment } = await presenter.call({ user }); + const environmentPath = v3EnvironmentPath(organization, project, environment); + + const suffix = page ? `/${page}` : ""; + + return redirect(`${environmentPath}${suffix}${search}`); + } catch (_e) { + const organization = await prisma.organization.findFirst({ + where: { + members: { + some: { + userId: user.id, + }, + }, + deletedAt: null, + }, + orderBy: { + createdAt: "desc", + }, + }); + + if (organization) { + return redirect(newProjectPath(organization)); + } + + return redirect(newOrganizationPath()); + } +}; diff --git a/apps/webapp/app/utils/deeplinkPages.test.ts b/apps/webapp/app/utils/deeplinkPages.test.ts new file mode 100644 index 000000000..0d7e47375 --- /dev/null +++ b/apps/webapp/app/utils/deeplinkPages.test.ts @@ -0,0 +1,317 @@ +import { flatRoutes } from "@remix-run/dev/dist/config/flat-routes.js"; +import type { RouteManifest } from "@remix-run/dev/dist/config/routes.js"; +import { matchPath } from "@remix-run/router"; +import { existsSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + DEEPLINK_PATH_PREFIX, + deeplinkSuffix, + ENV_PAGE_TARGETS, + resolveDeeplinkPage, +} from "./deeplinkPages"; + +const APP_DIR = join(__dirname, ".."); +const ROUTES_DIR = join(APP_DIR, "routes"); + +// The trailing dot excludes the environment layout route itself, which has no segment of its own. +const ENV_ROUTE_PREFIX = "_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam."; + +// Route files that name no deeplink: the environment root, and Remix's layout-opt-out spelling. +const NOT_DEEPLINK_NAMES = new Set(["_index", "queues_"]); + +const PROBE = "probe_01ABC"; + +const DEEPLINK_ROUTE_FILE = "routes/[_].$.ts"; + +const compiledRoutes: RouteManifest = flatRoutes(APP_DIR, ["**/.*"]); + +function compiledUrl(id: string): string { + if (!compiledRoutes[id]) throw new Error(`no compiled route with id ${id}`); + + const segments: string[] = []; + let route = compiledRoutes[id]; + while (route) { + if (route.path) segments.unshift(route.path); + route = route.parentId ? compiledRoutes[route.parentId] : undefined; + } + return `/${segments.join("/")}`; +} + +const COMPILED_DEEPLINK_PATH = (() => { + const entry = Object.values(compiledRoutes).find((route) => route.file === DEEPLINK_ROUTE_FILE); + if (!entry) throw new Error(`${DEEPLINK_ROUTE_FILE} is not in the compiled route manifest`); + return compiledUrl(entry.id).replace(/\/\*$/, ""); +})(); + +const routeEntries = readdirSync(ROUTES_DIR); + +function isRouteModule(entry: string): boolean { + const path = join(ROUTES_DIR, entry); + if (!statSync(path).isDirectory()) return true; + return existsSync(join(path, "route.tsx")) || existsSync(join(path, "route.ts")); +} + +// A trailing `_` only opts out of the parent layout: `queues_.$queueParam` serves `/queues/{id}`. +const envRoutes: string[][] = routeEntries + .filter((entry) => entry.startsWith(ENV_ROUTE_PREFIX) && isRouteModule(entry)) + .map((entry) => + entry + .slice(ENV_ROUTE_PREFIX.length) + .replace(/\.(tsx|ts)$/, "") + .split(".") + ) + .map((segments) => (segments.at(-1) === "_index" ? segments.slice(0, -1) : segments)) + .map((segments) => segments.map((segment) => segment.replace(/_+$/, ""))); + +function routeMatches(path: string, { allowParams }: { allowParams: boolean }): boolean { + const wanted = path === "" ? [] : path.split("/"); + return envRoutes.some( + (route) => + route.length === wanted.length && + route.every((segment, i) => (segment.startsWith("$") ? allowParams : segment === wanted[i])) + ); +} + +function envRouteSegments(): Set { + const segments = new Set(); + for (const entry of routeEntries) { + if (!entry.startsWith(ENV_ROUTE_PREFIX)) continue; + const segment = entry.slice(ENV_ROUTE_PREFIX.length).split(/[./]/)[0]; + if (!segment || segment === "ts" || segment === "tsx") continue; + segments.add(segment); + } + return segments; +} + +function descendantsOf(prefix: string): string[][] { + const depth = prefix === "" ? 0 : prefix.split("/").length; + return envRoutes + .filter((route) => route.length > depth && route.slice(0, depth).join("/") === prefix) + .map((route) => + route.slice(depth).map((segment) => (segment.startsWith("$") ? PROBE : segment)) + ); +} + +describe("deeplink targets", () => { + it("read enough routes for the assertions below to mean anything", () => { + expect(envRouteSegments().size).toBeGreaterThan(20); + expect(envRoutes.length).toBeGreaterThan(40); + }); + + it("every bare name lands on a real page that needs no id", () => { + const broken = [...ENV_PAGE_TARGETS.entries()] + .filter(([name]) => !routeMatches(resolveDeeplinkPage(name) ?? " ", { allowParams: false })) + .map(([name, { landing }]) => `${name} -> ${landing || "(environment root)"}`); + + expect(broken).toEqual([]); + }); + + it("every deep path lands on a real route, prefix graft included", () => { + const broken: string[] = []; + + for (const [name, { prefix }] of ENV_PAGE_TARGETS) { + for (const rest of descendantsOf(prefix)) { + const suffix = [name, ...rest].join("/"); + const resolved = resolveDeeplinkPage(suffix); + if (!routeMatches(resolved ?? " ", { allowParams: true })) { + broken.push(`${suffix} -> ${resolved}`); + } + } + } + + expect(broken).toEqual([]); + }); + + it("has deep paths worth checking", () => { + expect(descendantsOf("waitpoints/tokens").length).toBeGreaterThan(0); + expect(descendantsOf("tasks").length).toBeGreaterThan(2); + expect(descendantsOf("runs").length).toBeGreaterThan(0); + }); + + it("every environment page has a deeplink name", () => { + const missing = [...envRouteSegments()] + .filter((segment) => !NOT_DEEPLINK_NAMES.has(segment)) + .filter( + (segment) => routeMatches(segment, { allowParams: false }) && !ENV_PAGE_TARGETS.has(segment) + ) + .sort(); + + expect(missing).toEqual([]); + }); + + it("points a 404ing name elsewhere, and gives a redirect shim no name at all", () => { + for (const segment of ["tasks", "waitpoints", "metrics"]) { + expect(routeMatches(segment, { allowParams: false })).toBe(false); + } + + expect(ENV_PAGE_TARGETS.get("tasks")).toEqual({ landing: "", prefix: "tasks" }); + expect(ENV_PAGE_TARGETS.get("waitpoints")).toEqual({ + landing: "waitpoints/tokens", + prefix: "waitpoints/tokens", + }); + expect(ENV_PAGE_TARGETS.has("metrics")).toBe(false); + }); +}); + +describe("resolveDeeplinkPage", () => { + it("maps a bare name to its landing page", () => { + expect(resolveDeeplinkPage("apikeys")).toBe("apikeys"); + expect(resolveDeeplinkPage("waitpoints")).toBe("waitpoints/tokens"); + expect(resolveDeeplinkPage("tasks")).toBe(""); + }); + + it("grafts deeper segments onto the prefix", () => { + expect(resolveDeeplinkPage("runs/run_123")).toBe("runs/run_123"); + expect(resolveDeeplinkPage("tasks/standard/my-task")).toBe("tasks/standard/my-task"); + expect(resolveDeeplinkPage("waitpoints/waitpoint_123")).toBe("waitpoints/tokens/waitpoint_123"); + }); + + it("does not duplicate a prefix the caller already wrote out", () => { + expect(resolveDeeplinkPage("waitpoints/tokens")).toBe("waitpoints/tokens"); + expect(resolveDeeplinkPage("waitpoints/tokens/waitpoint_123")).toBe( + "waitpoints/tokens/waitpoint_123" + ); + }); + + it("rejects a name that is not a page", () => { + expect(resolveDeeplinkPage("")).toBeUndefined(); + expect(resolveDeeplinkPage("nonsense")).toBeUndefined(); + expect(resolveDeeplinkPage("metrics")).toBeUndefined(); + }); + + it("matches the page name whatever its case, and resolves it to the map's spelling", () => { + expect(resolveDeeplinkPage("APIKeys")).toBe("apikeys"); + expect(resolveDeeplinkPage("Waitpoints")).toBe("waitpoints/tokens"); + expect(resolveDeeplinkPage("TASKS")).toBe(""); + expect(resolveDeeplinkPage("Bulk-Actions")).toBe("bulk-actions"); + expect(resolveDeeplinkPage("Nonsense")).toBeUndefined(); + expect(resolveDeeplinkPage("Metrics")).toBeUndefined(); + }); + + it("leaves the case of everything after the name alone", () => { + expect(resolveDeeplinkPage("runs/run_ABC123")).toBe("runs/run_ABC123"); + expect(resolveDeeplinkPage("Runs/run_ABC123")).toBe("runs/run_ABC123"); + expect(resolveDeeplinkPage("TASKS/standard/My-Task")).toBe("tasks/standard/My-Task"); + expect(resolveDeeplinkPage("Waitpoints/waitpoint_ABC")).toBe("waitpoints/tokens/waitpoint_ABC"); + expect(resolveDeeplinkPage("Waitpoints/tokens/waitpoint_ABC")).toBe( + "waitpoints/tokens/waitpoint_ABC" + ); + expect(resolveDeeplinkPage("Tasks/standard/Group%2FMy-Task")).toBe( + "tasks/standard/Group%2FMy-Task" + ); + }); + + it("recognises a written-out prefix whatever its case, however many segments it spans", () => { + expect(resolveDeeplinkPage("Waitpoints/Tokens/wp_123")).toBe("waitpoints/tokens/wp_123"); + expect(resolveDeeplinkPage("waitpoints/Tokens/wp_123")).toBe("waitpoints/tokens/wp_123"); + expect(resolveDeeplinkPage("WAITPOINTS/TOKENS/wp_123")).toBe("waitpoints/tokens/wp_123"); + expect(resolveDeeplinkPage("Waitpoints/Tokens")).toBe("waitpoints/tokens"); + }); + + it("holds for every multi-segment prefix in the map, not just waitpoints", () => { + const multiSegment = [...ENV_PAGE_TARGETS.values()].filter(({ prefix }) => + prefix.includes("/") + ); + + expect(multiSegment.length).toBeGreaterThan(0); + + for (const { prefix } of multiSegment) { + const shouted = prefix + .split("/") + .map((segment) => segment.toUpperCase()) + .join("/"); + expect(resolveDeeplinkPage(`${shouted}/${PROBE}`)).toBe(`${prefix}/${PROBE}`); + expect(resolveDeeplinkPage(shouted)).toBe(prefix); + } + }); + + it("drops traversal segments, in plain and escaped spellings", () => { + expect(resolveDeeplinkPage("runs/../../../etc/passwd")).toBe("runs/etc/passwd"); + expect(resolveDeeplinkPage("../runs")).toBe("runs"); + expect(resolveDeeplinkPage("runs//run_1")).toBe("runs/run_1"); + expect(resolveDeeplinkPage("runs/%2e%2e/%2E%2E/run_1")).toBe("runs/run_1"); + expect(resolveDeeplinkPage("runs/%2e/run_1")).toBe("runs/run_1"); + expect(resolveDeeplinkPage("runs/%ZZ/run_1")).toBe("runs/run_1"); + }); + + it("passes encoded segments through without re-encoding them", () => { + expect(resolveDeeplinkPage("tasks/standard/group%2Fmy-task")).toBe( + "tasks/standard/group%2Fmy-task" + ); + expect(resolveDeeplinkPage("runs/a%3Fb%23c")).toBe("runs/a%3Fb%23c"); + // The slash stays escaped, so this addresses one odd id rather than climbing out. + expect(resolveDeeplinkPage("runs/..%2f..%2fetc")).toBe("runs/..%2f..%2fetc"); + }); +}); + +describe("the route Remix compiles from the filename", () => { + it("mounts the deeplink route at /_ and nowhere else", () => { + expect(COMPILED_DEEPLINK_PATH).toBe("/_"); + expect(COMPILED_DEEPLINK_PATH).toBe(DEEPLINK_PATH_PREFIX); + }); + + it("does not mount anything as a site-wide splat", () => { + const siteWide = Object.values(compiledRoutes) + .filter((route) => compiledUrl(route.id) === "/*") + .map((route) => route.file); + + expect(siteWide).toEqual([]); + }); + + it("compiled the manifest it is reading, paths and all", () => { + expect(Object.keys(compiledRoutes).length).toBeGreaterThan(400); + expect(compiledUrl("routes/login.magic")).toBe("/login/magic"); + expect( + compiledUrl( + "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam" + ) + ).toBe("/orgs/:organizationSlug/projects/:projectParam/env/:envParam/queues/:queueParam"); + }); +}); + +describe("deeplinkSuffix", () => { + it("strips the route's own prefix", () => { + expect(deeplinkSuffix("/_/tasks")).toBe("tasks"); + expect(deeplinkSuffix("/_/runs/run_123")).toBe("runs/run_123"); + }); + + it("keeps an escaped slash intact, unlike the decoded splat param", () => { + expect(deeplinkSuffix("/_/tasks/standard/group%2Fmy-task")).toBe( + "tasks/standard/group%2Fmy-task" + ); + }); + + it("strips only the prefix, leaving the remainder's case alone", () => { + expect(deeplinkSuffix("/_/runs/run_ABC123")).toBe("runs/run_ABC123"); + expect(deeplinkSuffix("/_/tasks/standard/My-Task")).toBe("tasks/standard/My-Task"); + }); + + it("matches the URL the router serves for it, splat case and all", () => { + const route = `${COMPILED_DEEPLINK_PATH}/*`; + expect(matchPath(route, "/_/apikeys")?.params["*"]).toBe("apikeys"); + expect(matchPath(route, "/_/runs/run_123")?.params["*"]).toBe("runs/run_123"); + expect(matchPath(route, "/_/APIKeys")?.params["*"]).toBe("APIKeys"); + expect(matchPath(route, "/deeplink/apikeys")).toBeNull(); + expect(matchPath(route, "/apikeys")).toBeNull(); + }); + + it("treats a bare prefix, a trailing slash and anything outside it as no suffix", () => { + expect(deeplinkSuffix("/_")).toBe(""); + expect(deeplinkSuffix("/_/")).toBe(""); + expect(deeplinkSuffix("/etc")).toBe(""); + expect(deeplinkSuffix("/_app/orgs")).toBe(""); + }); + + it("matches what the URL parser actually produces, keeping %2F and resolving %2e%2e", () => { + const encodedSlash = new URL("http://x/_/tasks/standard/group%2Fmy-task"); + expect(deeplinkSuffix(encodedSlash.pathname)).toBe("tasks/standard/group%2Fmy-task"); + expect(resolveDeeplinkPage(deeplinkSuffix(encodedSlash.pathname))).toBe( + "tasks/standard/group%2Fmy-task" + ); + + const traversal = new URL("http://x/_/runs/%2e%2e/%2e%2e/etc"); + expect(traversal.pathname).toBe("/etc"); + expect(resolveDeeplinkPage(deeplinkSuffix(traversal.pathname))).toBeUndefined(); + }); +}); diff --git a/apps/webapp/app/utils/deeplinkPages.ts b/apps/webapp/app/utils/deeplinkPages.ts new file mode 100644 index 000000000..bd626690f --- /dev/null +++ b/apps/webapp/app/utils/deeplinkPages.ts @@ -0,0 +1,77 @@ +export type DeeplinkTarget = { + landing: string; + prefix: string; +}; + +function page(name: string): DeeplinkTarget { + return { landing: name, prefix: name }; +} + +export const ENV_PAGE_TARGETS: ReadonlyMap = new Map([ + ["agents", page("agents")], + ["alerts", page("alerts")], + ["apikeys", page("apikeys")], + ["batches", page("batches")], + ["branches", page("branches")], + ["bulk-actions", page("bulk-actions")], + ["concurrency", page("concurrency")], + ["dashboards", page("dashboards")], + ["deployments", page("deployments")], + ["dev-branches", page("dev-branches")], + ["environment-variables", page("environment-variables")], + ["errors", page("errors")], + ["limits", page("limits")], + ["logs", page("logs")], + ["models", page("models")], + ["playground", page("playground")], + ["prompts", page("prompts")], + ["query", page("query")], + ["queues", page("queues")], + ["regions", page("regions")], + ["runs", page("runs")], + ["schedules", page("schedules")], + ["sessions", page("sessions")], + ["settings", page("settings")], + ["tasks", { landing: "", prefix: "tasks" }], + ["test", page("test")], + ["waitpoints", { landing: "waitpoints/tokens", prefix: "waitpoints/tokens" }], +]); + +export const DEEPLINK_PATH_PREFIX = "/_"; + +export function deeplinkSuffix(pathname: string): string { + const withSlash = `${DEEPLINK_PATH_PREFIX}/`; + if (!pathname.startsWith(withSlash)) return ""; + + return pathname.slice(withSlash.length); +} + +//`.` and `..`, plain or escaped as `%2e%2e`, would climb out of the environment path. +function isSafeSegment(segment: string): boolean { + if (segment.length === 0 || segment === "." || segment === "..") return false; + + let decoded: string; + try { + decoded = decodeURIComponent(segment); + } catch { + return false; + } + + return decoded !== "." && decoded !== ".."; +} + +export function resolveDeeplinkPage(suffix: string): string | undefined { + const segments = suffix.split("/").filter(isSafeSegment); + const [first = "", ...rest] = segments; + + const target = ENV_PAGE_TARGETS.get(first.toLowerCase()); + if (target === undefined) return undefined; + + if (rest.length === 0) return target.landing; + + const prefixDepth = target.prefix.split("/").length; + const writesPrefix = segments.slice(0, prefixDepth).join("/").toLowerCase() === target.prefix; + const beyondPrefix = writesPrefix ? segments.slice(prefixDepth) : rest; + + return [target.prefix, ...beyondPrefix].join("/"); +} From 7246f677dbeab6668d485a2ccca88a0498d8213a Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 7 Aug 2026 13:28:52 +0100 Subject: [PATCH 2/2] fix(webapp): strip null bytes from idempotency and debounce keys at trigger (#4527) ## What A trigger request carrying a Unicode NUL (`U+0000`) in the **idempotency key** or **debounce key** reached `prisma.taskRun.create()` and failed the insert, so the caller got an opaque 500 and the run was never created. These two keys are stored in `jsonb` columns (`idempotencyKeyOptions`, `debounce`), and Postgres rejects a NUL inside a `jsonb` value with `SQLSTATE 22P05` ("unsupported Unicode escape sequence ... cannot be converted to text"). This fix strips the NUL from both keys at the single trigger-input chokepoint (`#buildEngineTriggerInput`), which every trigger path flows through (single, batch item, mollified, and drainer replay). Stripping matches the existing precedent for run errors and task events. It does not change dedup behaviour: the idempotency **dedup identity** is the hashed key (a clean 64-char digest), computed independently of the raw key we clean, so dedup keeps working exactly as before. For debounce the key is used directly, so the cleaned key also becomes the grouping key, an acceptable change for input that is already malformed. ## Why not payload / metadata / tags Those are `text` columns fed by `JSON.stringify`, which escapes a NUL to a safe escape sequence, so they do not hit this failure on the normal JSON path. (A raw NUL in a `text` column throws a different code, `22021`, and is not what triggers this issue.) The observed failures are the `jsonb` `22P05` variant, which is only reachable via the two key fields. ## Evidence Red then green (containerTest, real Postgres): with the fix reverted, triggering through the real service with a NUL in `idempotencyKeyOptions.key` / `debounce.key` fails with the exact `22P05` signature; with the fix, the run is created and the stored key has the NUL removed. Full-stack e2e (isolated stack, real HTTP): `POST /api/v1/tasks/:taskId/trigger` with a NUL inside `idempotencyKeyOptions.key` (`"acmeinc"`) and, separately, `debounce.key` (`"grp1"`): - both returned `HTTP 200` with a created run (previously `500`) - stored `idempotencyKeyOptions` = `{ "key": "acmeinc", "scope": "run" }` (7 chars, NUL removed) - stored `debounce.key` = `"grp1"` (4 chars, NUL removed) - both runs render in the dashboard Unit tests cover the helper (strip, no-op fast path, object-reference reuse, null/undefined pass-through). ## Rollout / rollback Server-only webapp change, no flag. Zero behaviour change for clean input; only affects inputs that previously 500'd. Rollback is a straight revert, no data migration. ## Known limitation A raw NUL in a plain-string idempotency key (not created via `idempotencyKeys.create()`) lands in a `text` column and throws `22021` instead. That variant is not addressed here because stripping it would change the dedup identity, so it warrants a separate decision. Not observed in practice. refs TRI-13030 --- .../strip-null-bytes-trigger-keys.md | 6 + .../triggerTask.server.nullBytes.test.ts | 110 ++++++++++++++++++ .../runEngine/services/triggerTask.server.ts | 5 +- apps/webapp/app/utils/nullBytes.test.ts | 36 ++++++ apps/webapp/app/utils/nullBytes.ts | 26 +++++ 5 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 .server-changes/strip-null-bytes-trigger-keys.md create mode 100644 apps/webapp/app/runEngine/services/triggerTask.server.nullBytes.test.ts create mode 100644 apps/webapp/app/utils/nullBytes.test.ts create mode 100644 apps/webapp/app/utils/nullBytes.ts diff --git a/.server-changes/strip-null-bytes-trigger-keys.md b/.server-changes/strip-null-bytes-trigger-keys.md new file mode 100644 index 000000000..2ffa5b86f --- /dev/null +++ b/.server-changes/strip-null-bytes-trigger-keys.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Fixed a rare error where triggering a task could fail if the idempotency key or debounce key contained an invalid null character. The character is now removed automatically and the run is created as normal. diff --git a/apps/webapp/app/runEngine/services/triggerTask.server.nullBytes.test.ts b/apps/webapp/app/runEngine/services/triggerTask.server.nullBytes.test.ts new file mode 100644 index 000000000..9612f103e --- /dev/null +++ b/apps/webapp/app/runEngine/services/triggerTask.server.nullBytes.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, vi } from "vitest"; + +vi.mock("~/db.server", () => ({ + prisma: {}, + $replica: {}, + runOpsNewPrisma: {}, + runOpsLegacyPrisma: {}, + runOpsNewReplica: {}, + runOpsLegacyReplica: {}, +})); +vi.mock("~/v3/runOpsMigration/splitMode.server", () => ({ isSplitEnabled: async () => false })); +vi.mock("~/services/platform.v3.server", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + getEntitlement: vi.fn(), + }; +}); + +import { setupAuthenticatedEnvironment } from "@internal/run-engine/tests"; +import { assertNonNullable, containerTest } from "@internal/testcontainers"; +import { trace } from "@opentelemetry/api"; +import { IdempotencyKeyConcern } from "~/runEngine/concerns/idempotencyKeys.server"; +import { DefaultQueueManager } from "~/runEngine/concerns/queues.server"; +import { RunEngineTriggerTaskService } from "./triggerTask.server"; +import { + buildEngine, + CapturingParentRunValidator, + MockPayloadProcessor, + MockTraceEventConcern, +} from "./triggerTask.server.test.helpers"; + +vi.setConfig({ testTimeout: 60_000 }); + +const NUL = String.fromCharCode(0); + +function buildService(engine: any, prisma: any) { + return new RunEngineTriggerTaskService({ + engine, + prisma, + payloadProcessor: new MockPayloadProcessor(), + queueConcern: new DefaultQueueManager(prisma, engine), + idempotencyKeyConcern: new IdempotencyKeyConcern(prisma, engine, new MockTraceEventConcern()), + validator: new CapturingParentRunValidator(), + traceEventConcern: new MockTraceEventConcern(), + tracer: trace.getTracer("test", "0.0.0"), + metadataMaximumSize: 1024 * 1024 * 1, + }); +} + +describe("RunEngineTriggerTaskService null-byte sanitization", () => { + containerTest( + "strips a NUL from idempotencyKeyOptions.key so the jsonb insert does not 22P05", + async ({ prisma, redisOptions }) => { + const engine = buildEngine(prisma, redisOptions); + + try { + const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const service = buildService(engine, prisma); + + const result = await service.call({ + taskId: "nul-idem-task", + environment, + body: { + payload: { kind: "idem" }, + options: { + idempotencyKey: "a".repeat(64), + idempotencyKeyOptions: { key: `acme${NUL}inc`, scope: "run" }, + }, + }, + }); + assertNonNullable(result); + + const row = await prisma.taskRun.findUniqueOrThrow({ where: { id: result.run.id } }); + expect(row.idempotencyKeyOptions).toEqual({ key: "acmeinc", scope: "run" }); + } finally { + await engine.quit(); + } + } + ); + + containerTest( + "strips a NUL from debounce.key so the jsonb insert does not 22P05", + async ({ prisma, redisOptions }) => { + const engine = buildEngine(prisma, redisOptions); + + try { + const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const service = buildService(engine, prisma); + + const result = await service.call({ + taskId: "nul-debounce-task", + environment, + body: { + payload: { kind: "debounce" }, + options: { + debounce: { key: `grp${NUL}1`, delay: "1s" }, + }, + }, + }); + assertNonNullable(result); + + const row = await prisma.taskRun.findUniqueOrThrow({ where: { id: result.run.id } }); + expect((row.debounce as { key: string }).key).toBe("grp1"); + } finally { + await engine.quit(); + } + } + ); +}); diff --git a/apps/webapp/app/runEngine/services/triggerTask.server.ts b/apps/webapp/app/runEngine/services/triggerTask.server.ts index 34805d4c3..6d15c5543 100644 --- a/apps/webapp/app/runEngine/services/triggerTask.server.ts +++ b/apps/webapp/app/runEngine/services/triggerTask.server.ts @@ -25,6 +25,7 @@ import type { PrismaClientOrTransaction } from "@trigger.dev/database"; import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { parseDelay } from "~/utils/delays"; +import { removeNullBytesFromKey } from "~/utils/nullBytes"; import { handleMetadataPacket } from "~/utils/packets"; import { startSpan } from "~/v3/tracing.server"; import { resolveRunIdMintKind } from "~/v3/engineVersion.server"; @@ -906,7 +907,7 @@ export class RunEngineTriggerTaskService { environment: args.environment, idempotencyKey: args.idempotencyKey, idempotencyKeyExpiresAt: args.idempotencyKey ? args.idempotencyKeyExpiresAt : undefined, - idempotencyKeyOptions: args.body.options?.idempotencyKeyOptions, + idempotencyKeyOptions: removeNullBytesFromKey(args.body.options?.idempotencyKeyOptions), taskIdentifier: args.taskId, payload: args.payloadPacket.data ?? "", payloadType: args.payloadPacket.dataType, @@ -971,7 +972,7 @@ export class RunEngineTriggerTaskService { planType: args.planType, realtimeStreamsVersion: args.options.realtimeStreamsVersion, streamBasinName: args.environment.organization.streamBasinName, - debounce: args.body.options?.debounce, + debounce: removeNullBytesFromKey(args.body.options?.debounce), annotations: args.annotations, }; } diff --git a/apps/webapp/app/utils/nullBytes.test.ts b/apps/webapp/app/utils/nullBytes.test.ts new file mode 100644 index 000000000..98447f0cf --- /dev/null +++ b/apps/webapp/app/utils/nullBytes.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { removeNullBytes, removeNullBytesFromKey } from "./nullBytes"; + +describe("removeNullBytes", () => { + it("strips every NUL from a string", () => { + expect(removeNullBytes(`a\u0000b\u0000c`)).toBe("abc"); + }); + + it("returns the same reference when there is no NUL", () => { + const clean = "acme-inc"; + expect(removeNullBytes(clean)).toBe(clean); + }); + + it("passes through undefined and null", () => { + expect(removeNullBytes(undefined)).toBeUndefined(); + expect(removeNullBytes(null)).toBeNull(); + }); +}); + +describe("removeNullBytesFromKey", () => { + it("strips a NUL from the key while preserving other fields", () => { + expect(removeNullBytesFromKey({ key: `k\u00001`, scope: "run" })).toEqual({ + key: "k1", + scope: "run", + }); + }); + + it("returns the same object reference when the key is clean", () => { + const opts = { key: "clean", scope: "run" }; + expect(removeNullBytesFromKey(opts)).toBe(opts); + }); + + it("passes through undefined", () => { + expect(removeNullBytesFromKey(undefined)).toBeUndefined(); + }); +}); diff --git a/apps/webapp/app/utils/nullBytes.ts b/apps/webapp/app/utils/nullBytes.ts new file mode 100644 index 000000000..08c0a7341 --- /dev/null +++ b/apps/webapp/app/utils/nullBytes.ts @@ -0,0 +1,26 @@ +/** + * Removes Unicode NUL (U+0000) from a string. Postgres cannot store a NUL in a + * `text` column (SQLSTATE 22021) and rejects a `\u0000` escape when a JSON value + * is stored as `jsonb` (SQLSTATE 22P05), so a caller-supplied NUL reaching + * `taskRun.create()` fails the insert. The `indexOf` guard keeps the common + * (NUL-free) case allocation-free on the trigger hot path. + */ +export function removeNullBytes(value: T): T { + if (typeof value !== "string" || value.indexOf("\u0000") === -1) { + return value; + } + return value.replace(/\u0000/g, "") as T; +} + +/** + * Returns `value` with a NUL-stripped `key`, reusing the original object when no + * NUL is present. Used for the user-supplied idempotency-key and debounce + * options, whose `key` lands in a `jsonb` column on the TaskRun row. + */ +export function removeNullBytesFromKey(value: T): T { + if (!value) { + return value; + } + const cleaned = removeNullBytes(value.key); + return cleaned === value.key ? value : { ...value, key: cleaned }; +}