Merge branch 'main' into docs-ai-agents-chat-agent-guide
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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());
|
||||
}
|
||||
};
|
||||
@@ -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<string, unknown>;
|
||||
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();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
const segments = new Set<string>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, DeeplinkTarget> = 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("/");
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<T extends string | undefined | null>(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<T extends { key: string } | undefined>(value: T): T {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
const cleaned = removeNullBytes(value.key);
|
||||
return cleaned === value.key ? value : { ...value, key: cleaned };
|
||||
}
|
||||
Reference in New Issue
Block a user