From 53b88bba5d90a415bf95be29537c6dea5689feb5 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 30 Jun 2026 15:13:02 +0100 Subject: [PATCH 01/11] feat(runtime): add node-24/node-26, deprecate node(21), default new projects to node-24 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BuildRuntime enum: add node-24, node-26 - DEFAULT_RUNTIME: node → node-24 - buildImage.ts: repin node-22→22.23.1, add node-24/26 images, fix bun sidecar (node-20→node-22) - runtime.ts: add node-24/26 cases to all switches - config.ts: deprecation warning when runtime="node" (EOL node-21) - init.ts: scaffold node-24 as default for new projects TRI-11473 --- packages/cli-v3/src/commands/init.ts | 6 +++--- packages/cli-v3/src/config.ts | 9 ++++++++- packages/cli-v3/src/deploy/buildImage.ts | 15 ++++++++++----- packages/core/src/v3/build/runtime.ts | 10 ++++++++-- packages/core/src/v3/schemas/build.ts | 2 +- 5 files changed, 30 insertions(+), 12 deletions(-) diff --git a/packages/cli-v3/src/commands/init.ts b/packages/cli-v3/src/commands/init.ts index 34b941b0556..d97b286b13d 100644 --- a/packages/cli-v3/src/commands/init.ts +++ b/packages/cli-v3/src/commands/init.ts @@ -50,7 +50,7 @@ const InitCommandOptions = CommonCommandOptions.extend({ overrideConfig: z.boolean().default(false), tag: z.string().default(cliVersion), skipPackageInstall: z.boolean().default(false), - runtime: z.string().default("node"), + runtime: z.string().default("node-24"), pkgArgs: z.string().optional(), gitRef: z.string().default("main"), javascript: z.boolean().default(false), @@ -94,8 +94,8 @@ Examples: ) .option( "-r, --runtime ", - "Which runtime to use for the project. Currently only supports node and bun", - "node" + "Which runtime to use for the project. Supported: node-24 (default, LTS), node-22, node-26, bun", + "node-24" ) .option("--skip-package-install", "Skip installing the @trigger.dev/sdk package") .option("--override-config", "Override the existing config file if it exists") diff --git a/packages/cli-v3/src/config.ts b/packages/cli-v3/src/config.ts index 9751c4bdcba..6ecd50f4bc1 100644 --- a/packages/cli-v3/src/config.ts +++ b/packages/cli-v3/src/config.ts @@ -194,7 +194,7 @@ async function resolveConfig( ["run_engine_v2" as const].concat(config.compatibilityFlags ?? []) ); - const defaultRuntime: BuildRuntime = features.run_engine_v2 ? "node" : DEFAULT_RUNTIME; + const defaultRuntime: BuildRuntime = DEFAULT_RUNTIME; const mergedConfig = defu( { @@ -284,6 +284,13 @@ async function autoDetectDirs(workingDir: string): Promise { } function validateConfig(config: TriggerConfig, warn = true) { + if (config.runtime === "node") { + warn && + prettyWarning( + `The "node" runtime is deprecated (it used Node.js 21 which is EOL). Please migrate to "node-24" (recommended LTS) or "node-22". Update your trigger.config.ts: runtime: "node-24"` + ); + } + if (config.additionalFiles && config.additionalFiles.length > 0) { warn && prettyWarning( diff --git a/packages/cli-v3/src/deploy/buildImage.ts b/packages/cli-v3/src/deploy/buildImage.ts index abd0391fe98..071ceb48489 100644 --- a/packages/cli-v3/src/deploy/buildImage.ts +++ b/packages/cli-v3/src/deploy/buildImage.ts @@ -687,11 +687,14 @@ export type GenerateContainerfileOptions = { entrypoint: string; }; +// "node" (node-21) is deprecated. Existing configs using it will still work but +// new projects default to node-24. Remove in a future major version. const BASE_IMAGE: Record = { - bun: "imbios/bun-node:1.3.3-20-slim@sha256:59d84856a7e31eec83afedadb542f7306f672343b8b265c70d733404a6e8834b", - node: "node:21.7.3-bookworm-slim@sha256:dfc05dee209a1d7adf2ef189bd97396daad4e97c6eaa85778d6f75205ba1b0fb", - "node-22": - "node:22.16.0-bookworm-slim@sha256:048ed02c5fd52e86fda6fbd2f6a76cf0d4492fd6c6fee9e2c463ed5108da0e34", + bun: "imbios/bun-node:1.1.43-22-slim", + node: "node:22.23.1-bookworm-slim", + "node-22": "node:22.23.1-bookworm-slim", + "node-24": "node:24.18.0-bookworm-slim", + "node-26": "node:26.4.0-bookworm-slim", }; const DEFAULT_PACKAGES = ["busybox", "ca-certificates", "dumb-init", "git", "openssl"]; @@ -699,7 +702,9 @@ const DEFAULT_PACKAGES = ["busybox", "ca-certificates", "dumb-init", "git", "ope export async function generateContainerfile(options: GenerateContainerfileOptions) { switch (options.runtime) { case "node": - case "node-22": { + case "node-22": + case "node-24": + case "node-26": { return await generateNodeContainerfile(options); } case "bun": { diff --git a/packages/core/src/v3/build/runtime.ts b/packages/core/src/v3/build/runtime.ts index 1618a50ffd4..7230a33d3b2 100644 --- a/packages/core/src/v3/build/runtime.ts +++ b/packages/core/src/v3/build/runtime.ts @@ -4,12 +4,14 @@ import { BuildRuntime } from "../schemas/build.js"; import { dedupFlags } from "./flags.js"; import { homedir } from "node:os"; -export const DEFAULT_RUNTIME = "node" satisfies BuildRuntime; +export const DEFAULT_RUNTIME = "node-24" satisfies BuildRuntime; export function binaryForRuntime(runtime: BuildRuntime): string { switch (runtime) { case "node": case "node-22": + case "node-24": + case "node-26": return "node"; case "bun": return "bun"; @@ -22,6 +24,8 @@ export function execPathForRuntime(runtime: BuildRuntime): string { switch (runtime) { case "node": case "node-22": + case "node-24": + case "node-26": return process.execPath; case "bun": if (typeof process.env.BUN_INSTALL === "string") { @@ -50,7 +54,9 @@ export function execOptionsForRuntime( ): string { switch (runtime) { case "node": - case "node-22": { + case "node-22": + case "node-24": + case "node-26": { const importEntryPoint = options.loaderEntryPoint ? `--import=${pathToFileURL(options.loaderEntryPoint).href}` : undefined; diff --git a/packages/core/src/v3/schemas/build.ts b/packages/core/src/v3/schemas/build.ts index eeeaba73116..398f515a233 100644 --- a/packages/core/src/v3/schemas/build.ts +++ b/packages/core/src/v3/schemas/build.ts @@ -13,7 +13,7 @@ export const BuildTarget = z.enum(["dev", "deploy", "unmanaged"]); export type BuildTarget = z.infer; -export const BuildRuntime = z.enum(["node", "node-22", "bun"]); +export const BuildRuntime = z.enum(["node", "node-22", "node-24", "node-26", "bun"]); export type BuildRuntime = z.infer; From caf4b636c9d04973748d5cb562e25596953fca67 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 14 Jul 2026 14:29:58 +0100 Subject: [PATCH 02/11] fix(runtime): keep node as the default runtime --- packages/cli-v3/src/commands/init.ts | 6 +++--- packages/cli-v3/src/config.ts | 9 +-------- packages/cli-v3/src/deploy/buildImage.ts | 9 ++++----- packages/core/src/v3/build/runtime.ts | 2 +- 4 files changed, 9 insertions(+), 17 deletions(-) diff --git a/packages/cli-v3/src/commands/init.ts b/packages/cli-v3/src/commands/init.ts index d97b286b13d..f45839336f0 100644 --- a/packages/cli-v3/src/commands/init.ts +++ b/packages/cli-v3/src/commands/init.ts @@ -50,7 +50,7 @@ const InitCommandOptions = CommonCommandOptions.extend({ overrideConfig: z.boolean().default(false), tag: z.string().default(cliVersion), skipPackageInstall: z.boolean().default(false), - runtime: z.string().default("node-24"), + runtime: z.string().default("node"), pkgArgs: z.string().optional(), gitRef: z.string().default("main"), javascript: z.boolean().default(false), @@ -94,8 +94,8 @@ Examples: ) .option( "-r, --runtime ", - "Which runtime to use for the project. Supported: node-24 (default, LTS), node-22, node-26, bun", - "node-24" + "Which runtime to use for the project. Supported: node, node-22, node-24, node-26, bun", + "node" ) .option("--skip-package-install", "Skip installing the @trigger.dev/sdk package") .option("--override-config", "Override the existing config file if it exists") diff --git a/packages/cli-v3/src/config.ts b/packages/cli-v3/src/config.ts index 6ecd50f4bc1..9751c4bdcba 100644 --- a/packages/cli-v3/src/config.ts +++ b/packages/cli-v3/src/config.ts @@ -194,7 +194,7 @@ async function resolveConfig( ["run_engine_v2" as const].concat(config.compatibilityFlags ?? []) ); - const defaultRuntime: BuildRuntime = DEFAULT_RUNTIME; + const defaultRuntime: BuildRuntime = features.run_engine_v2 ? "node" : DEFAULT_RUNTIME; const mergedConfig = defu( { @@ -284,13 +284,6 @@ async function autoDetectDirs(workingDir: string): Promise { } function validateConfig(config: TriggerConfig, warn = true) { - if (config.runtime === "node") { - warn && - prettyWarning( - `The "node" runtime is deprecated (it used Node.js 21 which is EOL). Please migrate to "node-24" (recommended LTS) or "node-22". Update your trigger.config.ts: runtime: "node-24"` - ); - } - if (config.additionalFiles && config.additionalFiles.length > 0) { warn && prettyWarning( diff --git a/packages/cli-v3/src/deploy/buildImage.ts b/packages/cli-v3/src/deploy/buildImage.ts index 071ceb48489..a6c4afb1f90 100644 --- a/packages/cli-v3/src/deploy/buildImage.ts +++ b/packages/cli-v3/src/deploy/buildImage.ts @@ -687,12 +687,11 @@ export type GenerateContainerfileOptions = { entrypoint: string; }; -// "node" (node-21) is deprecated. Existing configs using it will still work but -// new projects default to node-24. Remove in a future major version. const BASE_IMAGE: Record = { - bun: "imbios/bun-node:1.1.43-22-slim", - node: "node:22.23.1-bookworm-slim", - "node-22": "node:22.23.1-bookworm-slim", + bun: "imbios/bun-node:1.3.3-20-slim@sha256:59d84856a7e31eec83afedadb542f7306f672343b8b265c70d733404a6e8834b", + node: "node:21.7.3-bookworm-slim@sha256:dfc05dee209a1d7adf2ef189bd97396daad4e97c6eaa85778d6f75205ba1b0fb", + "node-22": + "node:22.16.0-bookworm-slim@sha256:048ed02c5fd52e86fda6fbd2f6a76cf0d4492fd6c6fee9e2c463ed5108da0e34", "node-24": "node:24.18.0-bookworm-slim", "node-26": "node:26.4.0-bookworm-slim", }; diff --git a/packages/core/src/v3/build/runtime.ts b/packages/core/src/v3/build/runtime.ts index 7230a33d3b2..695b132fd8b 100644 --- a/packages/core/src/v3/build/runtime.ts +++ b/packages/core/src/v3/build/runtime.ts @@ -4,7 +4,7 @@ import { BuildRuntime } from "../schemas/build.js"; import { dedupFlags } from "./flags.js"; import { homedir } from "node:os"; -export const DEFAULT_RUNTIME = "node-24" satisfies BuildRuntime; +export const DEFAULT_RUNTIME = "node" satisfies BuildRuntime; export function binaryForRuntime(runtime: BuildRuntime): string { switch (runtime) { From 3cb2ff06dd9ebb3fe448713308f32ebbd1840007 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 14 Jul 2026 16:26:48 +0100 Subject: [PATCH 03/11] feat(runtime): add experimental Node.js 24 and 26 runtimes --- .changeset/experimental-node-runtimes.md | 6 ++ .../src/workloadManager/kubernetes.test.ts | 29 +++++++++ .../src/workloadManager/kubernetes.ts | 5 +- .../src/workloadManager/kubernetesPodSpec.ts | 15 +++++ packages/cli-v3/src/commands/init.ts | 2 +- packages/cli-v3/src/config.test.ts | 59 +++++++++++++++++++ packages/cli-v3/src/config.ts | 38 ++++++++---- packages/cli-v3/src/deploy/buildImage.test.ts | 28 +++++++++ packages/cli-v3/src/deploy/buildImage.ts | 6 +- packages/core/src/v3/build/resolvedConfig.ts | 40 +++++++------ packages/core/src/v3/build/runtime.test.ts | 45 ++++++++++++++ packages/core/src/v3/build/runtime.ts | 33 ++++++++++- packages/core/src/v3/config.ts | 4 +- packages/core/src/v3/schemas/build.ts | 12 ++++ 14 files changed, 283 insertions(+), 39 deletions(-) create mode 100644 .changeset/experimental-node-runtimes.md create mode 100644 apps/supervisor/src/workloadManager/kubernetes.test.ts create mode 100644 apps/supervisor/src/workloadManager/kubernetesPodSpec.ts create mode 100644 packages/cli-v3/src/config.test.ts create mode 100644 packages/cli-v3/src/deploy/buildImage.test.ts create mode 100644 packages/core/src/v3/build/runtime.test.ts diff --git a/.changeset/experimental-node-runtimes.md b/.changeset/experimental-node-runtimes.md new file mode 100644 index 00000000000..812fd47fb8f --- /dev/null +++ b/.changeset/experimental-node-runtimes.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/core": patch +"trigger.dev": patch +--- + +Add experimental Node.js 24 and 26 task runtimes. Set `runtime` to `experimental-node-24` or `experimental-node-26` in `trigger.config.ts`; the forward-compatible `node-24` and `node-26` keys are also accepted. diff --git a/apps/supervisor/src/workloadManager/kubernetes.test.ts b/apps/supervisor/src/workloadManager/kubernetes.test.ts new file mode 100644 index 00000000000..17d78e90db5 --- /dev/null +++ b/apps/supervisor/src/workloadManager/kubernetes.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { withRuntimeDefaultSeccompProfile } from "./kubernetesPodSpec.js"; + +describe("withRuntimeDefaultSeccompProfile", () => { + it("adds RuntimeDefault seccomp while preserving pod security defaults", () => { + const podSpec = withRuntimeDefaultSeccompProfile({ + restartPolicy: "Never", + automountServiceAccountToken: false, + securityContext: { + runAsNonRoot: true, + runAsUser: 1000, + fsGroup: 1000, + }, + }); + + expect(podSpec).toMatchObject({ + restartPolicy: "Never", + automountServiceAccountToken: false, + securityContext: { + runAsNonRoot: true, + runAsUser: 1000, + fsGroup: 1000, + seccompProfile: { + type: "RuntimeDefault", + }, + }, + }); + }); +}); diff --git a/apps/supervisor/src/workloadManager/kubernetes.ts b/apps/supervisor/src/workloadManager/kubernetes.ts index 762860104bd..ef056069cf9 100644 --- a/apps/supervisor/src/workloadManager/kubernetes.ts +++ b/apps/supervisor/src/workloadManager/kubernetes.ts @@ -14,6 +14,7 @@ import { PlacementTagProcessor } from "@trigger.dev/core/v3/serverOnly"; import { env } from "../env.js"; import { type K8sApi, createK8sApi, type k8s } from "../clients/kubernetes.js"; import { getRunnerId } from "../util.js"; +import { withRuntimeDefaultSeccompProfile } from "./kubernetesPodSpec.js"; type ResourceQuantities = { [K in "cpu" | "memory" | "ephemeral-storage"]?: string; @@ -309,7 +310,7 @@ export class KubernetesWorkloadManager implements WorkloadManager { } get #defaultPodSpec(): Omit { - return { + return withRuntimeDefaultSeccompProfile({ restartPolicy: "Never", automountServiceAccountToken: false, imagePullSecrets: this.getImagePullSecrets(), @@ -332,7 +333,7 @@ export class KubernetesWorkloadManager implements WorkloadManager { }, } : {}), - }; + }); } get #defaultResourceRequests(): ResourceQuantities { diff --git a/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts b/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts new file mode 100644 index 00000000000..6e8955206e5 --- /dev/null +++ b/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts @@ -0,0 +1,15 @@ +import type { k8s } from "../clients/kubernetes.js"; + +export function withRuntimeDefaultSeccompProfile( + podSpec: Omit +): Omit { + return { + ...podSpec, + securityContext: { + ...podSpec.securityContext, + seccompProfile: { + type: "RuntimeDefault", + }, + }, + }; +} diff --git a/packages/cli-v3/src/commands/init.ts b/packages/cli-v3/src/commands/init.ts index f45839336f0..8721e620501 100644 --- a/packages/cli-v3/src/commands/init.ts +++ b/packages/cli-v3/src/commands/init.ts @@ -94,7 +94,7 @@ Examples: ) .option( "-r, --runtime ", - "Which runtime to use for the project. Supported: node, node-22, node-24, node-26, bun", + "Which runtime to use for the project. Supported: node, node-22, bun", "node" ) .option("--skip-package-install", "Skip installing the @trigger.dev/sdk package") diff --git a/packages/cli-v3/src/config.test.ts b/packages/cli-v3/src/config.test.ts new file mode 100644 index 00000000000..3eb17237eca --- /dev/null +++ b/packages/cli-v3/src/config.test.ts @@ -0,0 +1,59 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadConfig } from "./config.js"; + +const projectDirs: string[] = []; + +afterEach(async () => { + await Promise.all(projectDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function createProject(runtime?: string) { + const cwd = await mkdtemp(join(tmpdir(), "trigger-runtime-config-")); + projectDirs.push(cwd); + + await mkdir(join(cwd, "trigger")); + await writeFile(join(cwd, "package.json"), JSON.stringify({ name: "runtime-config-test" })); + await writeFile(join(cwd, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + await writeFile( + join(cwd, "trigger.config.ts"), + `export default { + project: "proj_runtime_config_test", + maxDuration: 60, + dirs: ["./trigger"], + ${runtime === undefined ? "" : `runtime: ${JSON.stringify(runtime)},`} +}; +` + ); + + return cwd; +} + +describe("loadConfig runtime", () => { + it.each([ + ["experimental-node-24", "node-24"], + ["experimental-node-26", "node-26"], + ["node-24", "node-24"], + ["node-26", "node-26"], + ] as const)("normalizes %s before returning the resolved config", async (runtime, expected) => { + const cwd = await createProject(runtime); + + await expect(loadConfig({ cwd, warn: false })).resolves.toMatchObject({ runtime: expected }); + }); + + it("keeps node as the default", async () => { + const cwd = await createProject(); + + await expect(loadConfig({ cwd, warn: false })).resolves.toMatchObject({ runtime: "node" }); + }); + + it("rejects unsupported runtimes while loading config", async () => { + const cwd = await createProject("node-23"); + + await expect(loadConfig({ cwd, warn: false })).rejects.toThrowError( + /Unsupported runtime "node-23" in trigger\.config/ + ); + }); +}); diff --git a/packages/cli-v3/src/config.ts b/packages/cli-v3/src/config.ts index 9751c4bdcba..8b162fbc409 100644 --- a/packages/cli-v3/src/config.ts +++ b/packages/cli-v3/src/config.ts @@ -6,7 +6,11 @@ import type { TriggerConfig, } from "@trigger.dev/core/v3"; import type { ResolvedConfig } from "@trigger.dev/core/v3/build"; -import { DEFAULT_RUNTIME } from "@trigger.dev/core/v3/build"; +import { + DEFAULT_RUNTIME, + isExperimentalConfigRuntime, + resolveBuildRuntime, +} from "@trigger.dev/core/v3/build"; import * as c12 from "c12"; import { defu } from "defu"; import type * as esbuild from "esbuild"; @@ -170,6 +174,24 @@ async function resolveConfig( ); } + const config = + "config" in result.config ? (result.config.config as TriggerConfig) : result.config; + + const features = featuresFromCompatibilityFlags( + ["run_engine_v2" as const].concat(config.compatibilityFlags ?? []) + ); + const defaultRuntime: BuildRuntime = features.run_engine_v2 ? "node" : DEFAULT_RUNTIME; + const configuredRuntime = overrides?.runtime ?? config.runtime ?? defaultRuntime; + const runtime = resolveBuildRuntime(configuredRuntime); + + if (warn && isExperimentalConfigRuntime(configuredRuntime)) { + prettyWarning( + `The "${configuredRuntime}" runtime is experimental and may change before general availability.` + ); + } + + validateConfig(config, warn); + const packageJsonPath = await resolvePackageJSON(cwd); const tsconfigPath = await safeResolveTsConfig(cwd); const lockfilePath = await resolveLockfile(cwd); @@ -181,24 +203,14 @@ async function resolveConfig( ? dirname(packageJsonPath) : cwd; - const config = - "config" in result.config ? (result.config.config as TriggerConfig) : result.config; - - validateConfig(config, warn); - let dirs = config.dirs ? config.dirs : await autoDetectDirs(workingDir); dirs = dirs.map((dir) => resolveTriggerDir(dir, workingDir)); - const features = featuresFromCompatibilityFlags( - ["run_engine_v2" as const].concat(config.compatibilityFlags ?? []) - ); - - const defaultRuntime: BuildRuntime = features.run_engine_v2 ? "node" : DEFAULT_RUNTIME; - const mergedConfig = defu( { workingDir, + runtime, configFile: result.configFile, packageJsonPath, tsconfigPath, @@ -230,7 +242,7 @@ async function resolveConfig( ...mergedConfig, dirs: Array.from(new Set(dirs)), instrumentedPackageNames: getInstrumentedPackageNames(mergedConfig), - runtime: mergedConfig.runtime, + runtime, }; } diff --git a/packages/cli-v3/src/deploy/buildImage.test.ts b/packages/cli-v3/src/deploy/buildImage.test.ts new file mode 100644 index 00000000000..58ac10b229c --- /dev/null +++ b/packages/cli-v3/src/deploy/buildImage.test.ts @@ -0,0 +1,28 @@ +import type { BuildRuntime } from "@trigger.dev/core/v3/schemas"; +import { describe, expect, it } from "vitest"; +import { generateContainerfile } from "./buildImage.js"; + +const nodeImages: Array<[BuildRuntime, string]> = [ + [ + "node-24", + "node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d", + ], + [ + "node-26", + "node:26.4.0-bookworm-slim@sha256:ec82d089a8ae2cf02628da7b34ea57dc357b24db724d557fe2d240e6beb659c1", + ], +]; + +describe("generateContainerfile", () => { + it.each(nodeImages)("selects the pinned multiplatform image for %s", async (runtime, image) => { + const containerfile = await generateContainerfile({ + runtime, + build: {}, + image: undefined, + indexScript: "index.js", + entrypoint: "entrypoint.js", + }); + + expect(containerfile).toContain(`FROM ${image} AS base`); + }); +}); diff --git a/packages/cli-v3/src/deploy/buildImage.ts b/packages/cli-v3/src/deploy/buildImage.ts index a6c4afb1f90..8e393a70ddc 100644 --- a/packages/cli-v3/src/deploy/buildImage.ts +++ b/packages/cli-v3/src/deploy/buildImage.ts @@ -692,8 +692,10 @@ const BASE_IMAGE: Record = { node: "node:21.7.3-bookworm-slim@sha256:dfc05dee209a1d7adf2ef189bd97396daad4e97c6eaa85778d6f75205ba1b0fb", "node-22": "node:22.16.0-bookworm-slim@sha256:048ed02c5fd52e86fda6fbd2f6a76cf0d4492fd6c6fee9e2c463ed5108da0e34", - "node-24": "node:24.18.0-bookworm-slim", - "node-26": "node:26.4.0-bookworm-slim", + "node-24": + "node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d", + "node-26": + "node:26.4.0-bookworm-slim@sha256:ec82d089a8ae2cf02628da7b34ea57dc357b24db724d557fe2d240e6beb659c1", }; const DEFAULT_PACKAGES = ["busybox", "ca-certificates", "dumb-init", "git", "openssl"]; diff --git a/packages/core/src/v3/build/resolvedConfig.ts b/packages/core/src/v3/build/resolvedConfig.ts index 06caa8d2564..e8349013b3a 100644 --- a/packages/core/src/v3/build/resolvedConfig.ts +++ b/packages/core/src/v3/build/resolvedConfig.ts @@ -1,26 +1,30 @@ import { type Defu } from "defu"; import type { Prettify } from "ts-essentials"; -import { CompatibilityFlag, CompatibilityFlagFeatures, TriggerConfig } from "../config.js"; -import { BuildRuntime } from "../schemas/build.js"; -import { ResolveEnvironmentVariablesFunction } from "../types/index.js"; +import type { CompatibilityFlag, CompatibilityFlagFeatures, TriggerConfig } from "../config.js"; +import type { BuildRuntime } from "../schemas/build.js"; +import type { ResolveEnvironmentVariablesFunction } from "../types/index.js"; export type ResolvedConfig = Prettify< - Defu< - TriggerConfig, - [ - {}, - { - runtime: BuildRuntime; - dirs: string[]; - tsconfig: string; - build: { - jsx: { factory: string; fragment: string; automatic: true }; - } & Omit, "jsx">; - compatibilityFlags: CompatibilityFlag[]; - features: CompatibilityFlagFeatures; - }, - ] + Omit< + Defu< + TriggerConfig, + [ + {}, + { + runtime: BuildRuntime; + dirs: string[]; + tsconfig: string; + build: { + jsx: { factory: string; fragment: string; automatic: true }; + } & Omit, "jsx">; + compatibilityFlags: CompatibilityFlag[]; + features: CompatibilityFlagFeatures; + }, + ] + >, + "runtime" > & { + runtime: BuildRuntime; workingDir: string; workspaceDir: string; packageJsonPath: string; diff --git a/packages/core/src/v3/build/runtime.test.ts b/packages/core/src/v3/build/runtime.test.ts new file mode 100644 index 00000000000..a7403eb8b57 --- /dev/null +++ b/packages/core/src/v3/build/runtime.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { BuildManifest, BuildRuntime, ConfigRuntime, WorkerManifest } from "../schemas/build.js"; +import { isExperimentalConfigRuntime, resolveBuildRuntime } from "./runtime.js"; + +describe("runtime configuration", () => { + it.each([ + "node", + "node-22", + "node-24", + "node-26", + "experimental-node-24", + "experimental-node-26", + "bun", + ] as const)("accepts %s as a public config runtime", (runtime) => { + expect(ConfigRuntime.parse(runtime)).toBe(runtime); + }); + + it.each([ + ["experimental-node-24", "node-24"], + ["experimental-node-26", "node-26"], + ["node-24", "node-24"], + ["node-26", "node-26"], + ["node", "node"], + ["node-22", "node-22"], + ["bun", "bun"], + ] as const)("normalizes %s to %s", (runtime, expected) => { + expect(resolveBuildRuntime(runtime)).toBe(expected); + }); + + it("rejects unsupported config runtimes with a clear error", () => { + expect(() => resolveBuildRuntime("node-23")).toThrowError( + /Unsupported runtime "node-23" in trigger\.config\. Supported runtimes:/ + ); + }); + + it.each(["experimental-node-24", "experimental-node-26"] as const)( + "keeps %s out of internal runtime schemas", + (runtime) => { + expect(BuildRuntime.safeParse(runtime).success).toBe(false); + expect(BuildManifest.shape.runtime.safeParse(runtime).success).toBe(false); + expect(WorkerManifest.shape.runtime.safeParse(runtime).success).toBe(false); + expect(isExperimentalConfigRuntime(runtime)).toBe(true); + } + ); +}); diff --git a/packages/core/src/v3/build/runtime.ts b/packages/core/src/v3/build/runtime.ts index 695b132fd8b..10fcc962b78 100644 --- a/packages/core/src/v3/build/runtime.ts +++ b/packages/core/src/v3/build/runtime.ts @@ -1,11 +1,42 @@ import { join } from "node:path"; import { pathToFileURL } from "url"; -import { BuildRuntime } from "../schemas/build.js"; +import { BuildRuntime, ConfigRuntime } from "../schemas/build.js"; import { dedupFlags } from "./flags.js"; import { homedir } from "node:os"; export const DEFAULT_RUNTIME = "node" satisfies BuildRuntime; +export type ExperimentalConfigRuntime = "experimental-node-24" | "experimental-node-26"; + +export function isExperimentalConfigRuntime( + runtime: unknown +): runtime is ExperimentalConfigRuntime { + return runtime === "experimental-node-24" || runtime === "experimental-node-26"; +} + +export function resolveBuildRuntime(runtime: unknown): BuildRuntime { + const parsedRuntime = ConfigRuntime.safeParse(runtime); + + if (!parsedRuntime.success) { + const value = typeof runtime === "string" ? `"${runtime}"` : String(runtime); + + throw new Error( + `Unsupported runtime ${value} in trigger.config. Supported runtimes: ${ConfigRuntime.options.join( + ", " + )}.` + ); + } + + switch (parsedRuntime.data) { + case "experimental-node-24": + return "node-24"; + case "experimental-node-26": + return "node-26"; + default: + return parsedRuntime.data; + } +} + export function binaryForRuntime(runtime: BuildRuntime): string { switch (runtime) { case "node": diff --git a/packages/core/src/v3/config.ts b/packages/core/src/v3/config.ts index 7c15eb40cb0..3dc8e8f8597 100644 --- a/packages/core/src/v3/config.ts +++ b/packages/core/src/v3/config.ts @@ -7,7 +7,7 @@ import type { AnyOnInitHookFunction, AnyOnStartHookFunction, AnyOnSuccessHookFunction, - BuildRuntime, + ConfigRuntime, RetryOptions, } from "./index.js"; import type { LogLevel } from "./logger/taskLogger.js"; @@ -44,7 +44,7 @@ export type TriggerConfig = { /** * @default "node" */ - runtime?: BuildRuntime; + runtime?: ConfigRuntime; /** * Specify the project ref for your trigger.dev tasks. This is the project ref that you get when you create a new project in the trigger.dev dashboard. diff --git a/packages/core/src/v3/schemas/build.ts b/packages/core/src/v3/schemas/build.ts index 398f515a233..078c135edc4 100644 --- a/packages/core/src/v3/schemas/build.ts +++ b/packages/core/src/v3/schemas/build.ts @@ -13,6 +13,18 @@ export const BuildTarget = z.enum(["dev", "deploy", "unmanaged"]); export type BuildTarget = z.infer; +export const ConfigRuntime = z.enum([ + "node", + "node-22", + "node-24", + "node-26", + "experimental-node-24", + "experimental-node-26", + "bun", +]); + +export type ConfigRuntime = z.infer; + export const BuildRuntime = z.enum(["node", "node-22", "node-24", "node-26", "bun"]); export type BuildRuntime = z.infer; From bc38c3cdfaf746193d51658a7e49dfcd9d4d305c Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 14 Jul 2026 16:55:17 +0100 Subject: [PATCH 04/11] fix(runtime): gate new Node runtimes behind experimental aliases --- .changeset/experimental-node-runtimes.md | 2 +- packages/cli-v3/src/config.test.ts | 17 ++++++------- packages/core/src/v3/build/runtime.test.ts | 28 ++++++++++++---------- packages/core/src/v3/schemas/build.ts | 2 -- 4 files changed, 25 insertions(+), 24 deletions(-) diff --git a/.changeset/experimental-node-runtimes.md b/.changeset/experimental-node-runtimes.md index 812fd47fb8f..39ec1632129 100644 --- a/.changeset/experimental-node-runtimes.md +++ b/.changeset/experimental-node-runtimes.md @@ -3,4 +3,4 @@ "trigger.dev": patch --- -Add experimental Node.js 24 and 26 task runtimes. Set `runtime` to `experimental-node-24` or `experimental-node-26` in `trigger.config.ts`; the forward-compatible `node-24` and `node-26` keys are also accepted. +Add experimental Node.js 24 and 26 task runtimes. Set `runtime` to `experimental-node-24` or `experimental-node-26` in `trigger.config.ts`. diff --git a/packages/cli-v3/src/config.test.ts b/packages/cli-v3/src/config.test.ts index 3eb17237eca..910b2362420 100644 --- a/packages/cli-v3/src/config.test.ts +++ b/packages/cli-v3/src/config.test.ts @@ -35,8 +35,6 @@ describe("loadConfig runtime", () => { it.each([ ["experimental-node-24", "node-24"], ["experimental-node-26", "node-26"], - ["node-24", "node-24"], - ["node-26", "node-26"], ] as const)("normalizes %s before returning the resolved config", async (runtime, expected) => { const cwd = await createProject(runtime); @@ -49,11 +47,14 @@ describe("loadConfig runtime", () => { await expect(loadConfig({ cwd, warn: false })).resolves.toMatchObject({ runtime: "node" }); }); - it("rejects unsupported runtimes while loading config", async () => { - const cwd = await createProject("node-23"); + it.each(["node-24", "node-26", "node-23"])( + "rejects unsupported public runtime %s while loading config", + async (runtime) => { + const cwd = await createProject(runtime); - await expect(loadConfig({ cwd, warn: false })).rejects.toThrowError( - /Unsupported runtime "node-23" in trigger\.config/ - ); - }); + await expect(loadConfig({ cwd, warn: false })).rejects.toThrowError( + new RegExp(`Unsupported runtime "${runtime}" in trigger\\.config`) + ); + } + ); }); diff --git a/packages/core/src/v3/build/runtime.test.ts b/packages/core/src/v3/build/runtime.test.ts index a7403eb8b57..2c166f2b01f 100644 --- a/packages/core/src/v3/build/runtime.test.ts +++ b/packages/core/src/v3/build/runtime.test.ts @@ -3,23 +3,16 @@ import { BuildManifest, BuildRuntime, ConfigRuntime, WorkerManifest } from "../s import { isExperimentalConfigRuntime, resolveBuildRuntime } from "./runtime.js"; describe("runtime configuration", () => { - it.each([ - "node", - "node-22", - "node-24", - "node-26", - "experimental-node-24", - "experimental-node-26", - "bun", - ] as const)("accepts %s as a public config runtime", (runtime) => { - expect(ConfigRuntime.parse(runtime)).toBe(runtime); - }); + it.each(["node", "node-22", "experimental-node-24", "experimental-node-26", "bun"] as const)( + "accepts %s as a public config runtime", + (runtime) => { + expect(ConfigRuntime.parse(runtime)).toBe(runtime); + } + ); it.each([ ["experimental-node-24", "node-24"], ["experimental-node-26", "node-26"], - ["node-24", "node-24"], - ["node-26", "node-26"], ["node", "node"], ["node-22", "node-22"], ["bun", "bun"], @@ -27,6 +20,15 @@ describe("runtime configuration", () => { expect(resolveBuildRuntime(runtime)).toBe(expected); }); + it.each(["node-24", "node-26"] as const)( + "keeps internal runtime %s out of the public config schema", + (runtime) => { + expect(ConfigRuntime.safeParse(runtime).success).toBe(false); + expect(BuildRuntime.safeParse(runtime).success).toBe(true); + expect(() => resolveBuildRuntime(runtime)).toThrowError(/Unsupported runtime/); + } + ); + it("rejects unsupported config runtimes with a clear error", () => { expect(() => resolveBuildRuntime("node-23")).toThrowError( /Unsupported runtime "node-23" in trigger\.config\. Supported runtimes:/ diff --git a/packages/core/src/v3/schemas/build.ts b/packages/core/src/v3/schemas/build.ts index 078c135edc4..1ab5b5b8169 100644 --- a/packages/core/src/v3/schemas/build.ts +++ b/packages/core/src/v3/schemas/build.ts @@ -16,8 +16,6 @@ export type BuildTarget = z.infer; export const ConfigRuntime = z.enum([ "node", "node-22", - "node-24", - "node-26", "experimental-node-24", "experimental-node-26", "bun", From 0ea5f74fdf1e66745aa7f845204e3ef59085a61d Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 14 Jul 2026 20:07:14 +0100 Subject: [PATCH 05/11] feat: scope RuntimeDefault seccomp profile to node-24+ run pods Only node-24+ runtimes create io_uring file descriptors (libuv >= ~1.52) that block CRIU checkpointing; node/node-22/bun do not. Apply the RuntimeDefault seccomp profile only to those cold pods instead of all run pods, so existing runtimes keep their current syscall surface. Plumbs the canonical runtime identifier through the dequeue message (backgroundWorker.runtime) to the Kubernetes workload manager. --- apps/supervisor/src/index.ts | 1 + .../supervisor/src/workloadManager/kubernetes.ts | 16 ++++++++++++---- .../src/workloadManager/kubernetesPodSpec.ts | 16 ++++++++++++++++ apps/supervisor/src/workloadManager/types.ts | 3 +++ .../src/engine/systems/dequeueSystem.ts | 1 + packages/core/src/v3/schemas/runEngine.ts | 3 +++ 6 files changed, 36 insertions(+), 4 deletions(-) diff --git a/apps/supervisor/src/index.ts b/apps/supervisor/src/index.ts index aeda154c355..06e49288d1a 100644 --- a/apps/supervisor/src/index.ts +++ b/apps/supervisor/src/index.ts @@ -615,6 +615,7 @@ class ManagedSupervisor { projectId: message.project.id, deploymentFriendlyId: message.deployment.friendlyId, deploymentVersion: message.backgroundWorker.version, + runtime: message.backgroundWorker.runtime, runId: message.run.id, runFriendlyId: message.run.friendlyId, version: message.version, diff --git a/apps/supervisor/src/workloadManager/kubernetes.ts b/apps/supervisor/src/workloadManager/kubernetes.ts index ef056069cf9..d9aaa628ce0 100644 --- a/apps/supervisor/src/workloadManager/kubernetes.ts +++ b/apps/supervisor/src/workloadManager/kubernetes.ts @@ -14,7 +14,10 @@ import { PlacementTagProcessor } from "@trigger.dev/core/v3/serverOnly"; import { env } from "../env.js"; import { type K8sApi, createK8sApi, type k8s } from "../clients/kubernetes.js"; import { getRunnerId } from "../util.js"; -import { withRuntimeDefaultSeccompProfile } from "./kubernetesPodSpec.js"; +import { + runtimeRequiresSeccompProfile, + withRuntimeDefaultSeccompProfile, +} from "./kubernetesPodSpec.js"; type ResourceQuantities = { [K in "cpu" | "memory" | "ephemeral-storage"]?: string; @@ -106,6 +109,11 @@ export class KubernetesWorkloadManager implements WorkloadManager { const runnerId = getRunnerId(opts.runFriendlyId, opts.nextAttemptNumber); try { + const basePodSpec = this.addPlacementTags(this.#defaultPodSpec, opts.placementTags); + const podSpec = runtimeRequiresSeccompProfile(opts.runtime) + ? withRuntimeDefaultSeccompProfile(basePodSpec) + : basePodSpec; + await this.k8s.core.createNamespacedPod({ namespace: this.namespace, body: { @@ -120,7 +128,7 @@ export class KubernetesWorkloadManager implements WorkloadManager { }, }, spec: { - ...this.addPlacementTags(this.#defaultPodSpec, opts.placementTags), + ...podSpec, affinity: this.#getAffinity(opts), tolerations: this.#getScheduleTolerations(this.#isScheduledRun(opts)), terminationGracePeriodSeconds: 60 * 60, @@ -310,7 +318,7 @@ export class KubernetesWorkloadManager implements WorkloadManager { } get #defaultPodSpec(): Omit { - return withRuntimeDefaultSeccompProfile({ + return { restartPolicy: "Never", automountServiceAccountToken: false, imagePullSecrets: this.getImagePullSecrets(), @@ -333,7 +341,7 @@ export class KubernetesWorkloadManager implements WorkloadManager { }, } : {}), - }); + }; } get #defaultResourceRequests(): ResourceQuantities { diff --git a/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts b/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts index 6e8955206e5..6984e43a99a 100644 --- a/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts +++ b/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts @@ -1,5 +1,21 @@ import type { k8s } from "../clients/kubernetes.js"; +/** + * Node >= 24 (libuv >= ~1.52) unconditionally creates io_uring file descriptors, + * which cannot be checkpointed. Launching those pods under the RuntimeDefault + * seccomp profile makes io_uring_setup fail so libuv falls back to epoll, keeping + * the pod checkpointable. "node" (21.x), "node-22" (UV_USE_IO_URING=0 still works) + * and "bun" do not create these descriptors, so the profile is scoped to node-24+ + * to avoid changing the syscall surface of existing runtimes. + * + * Tolerant of an "experimental-" prefix in case a non-normalized value reaches here. + */ +export function runtimeRequiresSeccompProfile(runtime: string | null | undefined): boolean { + if (!runtime) return false; + const match = /^(?:experimental-)?node-(\d+)$/.exec(runtime); + return match ? Number(match[1]) >= 24 : false; +} + export function withRuntimeDefaultSeccompProfile( podSpec: Omit ): Omit { diff --git a/apps/supervisor/src/workloadManager/types.ts b/apps/supervisor/src/workloadManager/types.ts index ee759cb8a79..d3f84af679f 100644 --- a/apps/supervisor/src/workloadManager/types.ts +++ b/apps/supervisor/src/workloadManager/types.ts @@ -40,6 +40,9 @@ export interface WorkloadManagerCreateOptions { projectId: string; deploymentFriendlyId: string; deploymentVersion: string; + // Canonical runtime identifier (e.g. "node", "node-22", "node-24"). Used to + // scope the RuntimeDefault seccomp profile to runtimes that require it. + runtime?: string; runId: string; runFriendlyId: string; snapshotId: string; diff --git a/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts index b235fa3b3e3..28918ce6f47 100644 --- a/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts @@ -597,6 +597,7 @@ export class DequeueSystem { id: result.worker.id, friendlyId: result.worker.friendlyId, version: result.worker.version, + runtime: result.worker.runtime ?? undefined, }, // TODO: use a discriminated union schema to differentiate between dequeued runs in dev and in deployed environments. // Would help make the typechecking stricter diff --git a/packages/core/src/v3/schemas/runEngine.ts b/packages/core/src/v3/schemas/runEngine.ts index f4342ec2600..c6a3580702c 100644 --- a/packages/core/src/v3/schemas/runEngine.ts +++ b/packages/core/src/v3/schemas/runEngine.ts @@ -272,6 +272,9 @@ export const DequeuedMessage = z.object({ id: z.string(), friendlyId: z.string(), version: z.string(), + // Canonical runtime identifier (e.g. "node", "node-22", "node-24", "bun"). + // Consumed by orchestrators to decide per-runtime pod settings. + runtime: z.string().optional(), }), deployment: z.object({ id: z.string().optional(), From 5cc684023995da820e8a6550b44d776c97345114 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 15 Jul 2026 08:55:44 +0100 Subject: [PATCH 06/11] refactor: use targeted io_uring seccomp profile instead of RuntimeDefault Replace the RuntimeDefault seccomp profile on node-24+ run pods with a Localhost profile that blocks only io_uring_setup/enter/register and allows every other syscall. This keeps node-24+ pods CRIU-checkpointable (libuv falls back to epoll) without RuntimeDefault's broad syscall allowlist, which could break unrelated workloads (browsers, sandboxes, native threads). Still scoped to node-24+; node/node-22/bun pods remain unconfined. --- .../src/workloadManager/kubernetes.test.ts | 33 ++++++++++++++++--- .../src/workloadManager/kubernetes.ts | 4 +-- .../src/workloadManager/kubernetesPodSpec.ts | 29 ++++++++++++---- apps/supervisor/src/workloadManager/types.ts | 2 +- 4 files changed, 53 insertions(+), 15 deletions(-) diff --git a/apps/supervisor/src/workloadManager/kubernetes.test.ts b/apps/supervisor/src/workloadManager/kubernetes.test.ts index 17d78e90db5..bdec270f753 100644 --- a/apps/supervisor/src/workloadManager/kubernetes.test.ts +++ b/apps/supervisor/src/workloadManager/kubernetes.test.ts @@ -1,9 +1,31 @@ import { describe, expect, it } from "vitest"; -import { withRuntimeDefaultSeccompProfile } from "./kubernetesPodSpec.js"; +import { + BLOCK_IO_URING_SECCOMP_PROFILE, + runtimeRequiresSeccompProfile, + withBlockIoUringSeccompProfile, +} from "./kubernetesPodSpec.js"; -describe("withRuntimeDefaultSeccompProfile", () => { - it("adds RuntimeDefault seccomp while preserving pod security defaults", () => { - const podSpec = withRuntimeDefaultSeccompProfile({ +describe("runtimeRequiresSeccompProfile", () => { + it("returns true for node-24 and above", () => { + expect(runtimeRequiresSeccompProfile("node-24")).toBe(true); + expect(runtimeRequiresSeccompProfile("node-26")).toBe(true); + expect(runtimeRequiresSeccompProfile("node-30")).toBe(true); + expect(runtimeRequiresSeccompProfile("experimental-node-24")).toBe(true); + }); + + it("returns false for runtimes that do not create io_uring fds", () => { + expect(runtimeRequiresSeccompProfile("node")).toBe(false); + expect(runtimeRequiresSeccompProfile("node-22")).toBe(false); + expect(runtimeRequiresSeccompProfile("bun")).toBe(false); + expect(runtimeRequiresSeccompProfile(undefined)).toBe(false); + expect(runtimeRequiresSeccompProfile(null)).toBe(false); + expect(runtimeRequiresSeccompProfile("")).toBe(false); + }); +}); + +describe("withBlockIoUringSeccompProfile", () => { + it("adds the Localhost io_uring profile while preserving pod security defaults", () => { + const podSpec = withBlockIoUringSeccompProfile({ restartPolicy: "Never", automountServiceAccountToken: false, securityContext: { @@ -21,7 +43,8 @@ describe("withRuntimeDefaultSeccompProfile", () => { runAsUser: 1000, fsGroup: 1000, seccompProfile: { - type: "RuntimeDefault", + type: "Localhost", + localhostProfile: BLOCK_IO_URING_SECCOMP_PROFILE, }, }, }); diff --git a/apps/supervisor/src/workloadManager/kubernetes.ts b/apps/supervisor/src/workloadManager/kubernetes.ts index d9aaa628ce0..455a821c96d 100644 --- a/apps/supervisor/src/workloadManager/kubernetes.ts +++ b/apps/supervisor/src/workloadManager/kubernetes.ts @@ -16,7 +16,7 @@ import { type K8sApi, createK8sApi, type k8s } from "../clients/kubernetes.js"; import { getRunnerId } from "../util.js"; import { runtimeRequiresSeccompProfile, - withRuntimeDefaultSeccompProfile, + withBlockIoUringSeccompProfile, } from "./kubernetesPodSpec.js"; type ResourceQuantities = { @@ -111,7 +111,7 @@ export class KubernetesWorkloadManager implements WorkloadManager { try { const basePodSpec = this.addPlacementTags(this.#defaultPodSpec, opts.placementTags); const podSpec = runtimeRequiresSeccompProfile(opts.runtime) - ? withRuntimeDefaultSeccompProfile(basePodSpec) + ? withBlockIoUringSeccompProfile(basePodSpec) : basePodSpec; await this.k8s.core.createNamespacedPod({ diff --git a/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts b/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts index 6984e43a99a..dcf0dd4c41d 100644 --- a/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts +++ b/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts @@ -1,12 +1,20 @@ import type { k8s } from "../clients/kubernetes.js"; +/** + * Path (relative to the kubelet seccomp root, /var/lib/kubelet/seccomp) of the + * targeted seccomp profile that blocks only io_uring_setup/enter/register and + * allows every other syscall. Must match the profile distributed to worker nodes + * by the infra kubeadm config. + */ +export const BLOCK_IO_URING_SECCOMP_PROFILE = "profiles/block-io-uring.json"; + /** * Node >= 24 (libuv >= ~1.52) unconditionally creates io_uring file descriptors, - * which cannot be checkpointed. Launching those pods under the RuntimeDefault - * seccomp profile makes io_uring_setup fail so libuv falls back to epoll, keeping - * the pod checkpointable. "node" (21.x), "node-22" (UV_USE_IO_URING=0 still works) - * and "bun" do not create these descriptors, so the profile is scoped to node-24+ - * to avoid changing the syscall surface of existing runtimes. + * which cannot be checkpointed. Launching those pods under a seccomp profile that + * fails io_uring_setup makes libuv fall back to epoll, keeping the pod + * checkpointable. "node" (21.x), "node-22" (UV_USE_IO_URING=0 still works) and + * "bun" do not create these descriptors, so the profile is scoped to node-24+ to + * avoid changing the syscall surface of existing runtimes. * * Tolerant of an "experimental-" prefix in case a non-normalized value reaches here. */ @@ -16,7 +24,13 @@ export function runtimeRequiresSeccompProfile(runtime: string | null | undefined return match ? Number(match[1]) >= 24 : false; } -export function withRuntimeDefaultSeccompProfile( +/** + * Applies the targeted Localhost profile that blocks only io_uring, preserving any + * existing security-context fields. Unlike RuntimeDefault this restricts no other + * syscalls, so it cannot break unrelated workloads (browsers, sandboxes, native + * threads). Only call this for runtimes where runtimeRequiresSeccompProfile is true. + */ +export function withBlockIoUringSeccompProfile( podSpec: Omit ): Omit { return { @@ -24,7 +38,8 @@ export function withRuntimeDefaultSeccompProfile( securityContext: { ...podSpec.securityContext, seccompProfile: { - type: "RuntimeDefault", + type: "Localhost", + localhostProfile: BLOCK_IO_URING_SECCOMP_PROFILE, }, }, }; diff --git a/apps/supervisor/src/workloadManager/types.ts b/apps/supervisor/src/workloadManager/types.ts index d3f84af679f..7e3a05d0511 100644 --- a/apps/supervisor/src/workloadManager/types.ts +++ b/apps/supervisor/src/workloadManager/types.ts @@ -41,7 +41,7 @@ export interface WorkloadManagerCreateOptions { deploymentFriendlyId: string; deploymentVersion: string; // Canonical runtime identifier (e.g. "node", "node-22", "node-24"). Used to - // scope the RuntimeDefault seccomp profile to runtimes that require it. + // scope the io_uring-blocking seccomp profile to runtimes that require it. runtime?: string; runId: string; runFriendlyId: string; From 35eff675a544fbd8ec8a131a954b88785b5f0441 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 15 Jul 2026 14:20:08 +0100 Subject: [PATCH 07/11] disable for self-hosters --- apps/supervisor/src/index.ts | 1 + apps/supervisor/src/workloadManager/kubernetes.ts | 10 +++++++--- apps/supervisor/src/workloadManager/types.ts | 5 +++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/supervisor/src/index.ts b/apps/supervisor/src/index.ts index 06e49288d1a..701b8a25e16 100644 --- a/apps/supervisor/src/index.ts +++ b/apps/supervisor/src/index.ts @@ -120,6 +120,7 @@ class ManagedSupervisor { snapshotPollIntervalSeconds: env.RUNNER_SNAPSHOT_POLL_INTERVAL_SECONDS, additionalEnvVars: env.RUNNER_ADDITIONAL_ENV_VARS, dockerAutoremove: env.DOCKER_AUTOREMOVE_EXITED_CONTAINERS, + checkpointsEnabled: !!env.TRIGGER_CHECKPOINT_URL, } satisfies WorkloadManagerOptions; this.resourceMonitor = env.RESOURCE_MONITOR_ENABLED diff --git a/apps/supervisor/src/workloadManager/kubernetes.ts b/apps/supervisor/src/workloadManager/kubernetes.ts index 455a821c96d..3aa9b62be66 100644 --- a/apps/supervisor/src/workloadManager/kubernetes.ts +++ b/apps/supervisor/src/workloadManager/kubernetes.ts @@ -110,9 +110,13 @@ export class KubernetesWorkloadManager implements WorkloadManager { try { const basePodSpec = this.addPlacementTags(this.#defaultPodSpec, opts.placementTags); - const podSpec = runtimeRequiresSeccompProfile(opts.runtime) - ? withBlockIoUringSeccompProfile(basePodSpec) - : basePodSpec; + // The io_uring-blocking profile is only needed to keep pods checkpointable. + // Skip it unless checkpointing is enabled - self-hosters don't have the + // profile provisioned on their nodes, so applying it would fail pod creation. + const podSpec = + this.opts.checkpointsEnabled && runtimeRequiresSeccompProfile(opts.runtime) + ? withBlockIoUringSeccompProfile(basePodSpec) + : basePodSpec; await this.k8s.core.createNamespacedPod({ namespace: this.namespace, diff --git a/apps/supervisor/src/workloadManager/types.ts b/apps/supervisor/src/workloadManager/types.ts index 7e3a05d0511..66a906568c6 100644 --- a/apps/supervisor/src/workloadManager/types.ts +++ b/apps/supervisor/src/workloadManager/types.ts @@ -16,6 +16,11 @@ export interface WorkloadManagerOptions { snapshotPollIntervalSeconds?: number; additionalEnvVars?: Record; dockerAutoremove?: boolean; + // Whether CRIU checkpoint/restore is enabled for this deployment. Only when + // checkpointing is enabled do node-24+ pods need the io_uring-blocking seccomp + // profile (io_uring FDs can't be checkpointed). Self-hosters without + // checkpointing don't need it - and don't have the profile on their nodes. + checkpointsEnabled?: boolean; } export interface WorkloadManager { From dfaa0e5f9db4fe0c26d84ab2acaf86576af9831d Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 15 Jul 2026 15:06:16 +0100 Subject: [PATCH 08/11] chore: shorten comments in kubernetesPodSpec.ts --- .../src/workloadManager/kubernetesPodSpec.ts | 23 ++++++------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts b/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts index dcf0dd4c41d..d47d5d7c740 100644 --- a/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts +++ b/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts @@ -1,22 +1,15 @@ import type { k8s } from "../clients/kubernetes.js"; /** - * Path (relative to the kubelet seccomp root, /var/lib/kubelet/seccomp) of the - * targeted seccomp profile that blocks only io_uring_setup/enter/register and - * allows every other syscall. Must match the profile distributed to worker nodes - * by the infra kubeadm config. + * Relative path (kubelet seccomp root) of the profile blocking only io_uring + * syscalls. Must match the profile deployed to worker nodes. */ export const BLOCK_IO_URING_SECCOMP_PROFILE = "profiles/block-io-uring.json"; /** - * Node >= 24 (libuv >= ~1.52) unconditionally creates io_uring file descriptors, - * which cannot be checkpointed. Launching those pods under a seccomp profile that - * fails io_uring_setup makes libuv fall back to epoll, keeping the pod - * checkpointable. "node" (21.x), "node-22" (UV_USE_IO_URING=0 still works) and - * "bun" do not create these descriptors, so the profile is scoped to node-24+ to - * avoid changing the syscall surface of existing runtimes. - * - * Tolerant of an "experimental-" prefix in case a non-normalized value reaches here. + * Node >= 24 always creates io_uring fds, which can't be checkpointed. Blocking + * io_uring_setup makes libuv fall back to epoll. Other runtimes don't need this, + * so it's scoped to node-24+. Tolerates an "experimental-" prefix. */ export function runtimeRequiresSeccompProfile(runtime: string | null | undefined): boolean { if (!runtime) return false; @@ -25,10 +18,8 @@ export function runtimeRequiresSeccompProfile(runtime: string | null | undefined } /** - * Applies the targeted Localhost profile that blocks only io_uring, preserving any - * existing security-context fields. Unlike RuntimeDefault this restricts no other - * syscalls, so it cannot break unrelated workloads (browsers, sandboxes, native - * threads). Only call this for runtimes where runtimeRequiresSeccompProfile is true. + * Applies the io_uring-blocking seccomp profile, preserving existing + * security-context fields. Only call when runtimeRequiresSeccompProfile is true. */ export function withBlockIoUringSeccompProfile( podSpec: Omit From b3b1af9e03ea37a99238ccd18e55c54d40a0d958 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 15 Jul 2026 15:09:33 +0100 Subject: [PATCH 09/11] reduce comments --- .../src/workloadManager/kubernetes.test.ts | 73 ++++++++----------- .../src/workloadManager/kubernetes.ts | 12 +-- .../src/workloadManager/kubernetesPodSpec.ts | 20 ++--- apps/supervisor/src/workloadManager/types.ts | 8 +- packages/core/src/v3/schemas/runEngine.ts | 3 +- 5 files changed, 44 insertions(+), 72 deletions(-) diff --git a/apps/supervisor/src/workloadManager/kubernetes.test.ts b/apps/supervisor/src/workloadManager/kubernetes.test.ts index bdec270f753..701f063fd39 100644 --- a/apps/supervisor/src/workloadManager/kubernetes.test.ts +++ b/apps/supervisor/src/workloadManager/kubernetes.test.ts @@ -1,52 +1,37 @@ import { describe, expect, it } from "vitest"; -import { - BLOCK_IO_URING_SECCOMP_PROFILE, - runtimeRequiresSeccompProfile, - withBlockIoUringSeccompProfile, -} from "./kubernetesPodSpec.js"; +import { BLOCK_IO_URING_SECCOMP_PROFILE, withBlockIoUringSeccompProfile } from "./kubernetesPodSpec.js"; -describe("runtimeRequiresSeccompProfile", () => { - it("returns true for node-24 and above", () => { - expect(runtimeRequiresSeccompProfile("node-24")).toBe(true); - expect(runtimeRequiresSeccompProfile("node-26")).toBe(true); - expect(runtimeRequiresSeccompProfile("node-30")).toBe(true); - expect(runtimeRequiresSeccompProfile("experimental-node-24")).toBe(true); - }); - - it("returns false for runtimes that do not create io_uring fds", () => { - expect(runtimeRequiresSeccompProfile("node")).toBe(false); - expect(runtimeRequiresSeccompProfile("node-22")).toBe(false); - expect(runtimeRequiresSeccompProfile("bun")).toBe(false); - expect(runtimeRequiresSeccompProfile(undefined)).toBe(false); - expect(runtimeRequiresSeccompProfile(null)).toBe(false); - expect(runtimeRequiresSeccompProfile("")).toBe(false); - }); -}); +const basePodSpec = { + restartPolicy: "Never" as const, + automountServiceAccountToken: false, + securityContext: { + runAsNonRoot: true, + runAsUser: 1000, + fsGroup: 1000, + }, +}; describe("withBlockIoUringSeccompProfile", () => { - it("adds the Localhost io_uring profile while preserving pod security defaults", () => { - const podSpec = withBlockIoUringSeccompProfile({ - restartPolicy: "Never", - automountServiceAccountToken: false, - securityContext: { - runAsNonRoot: true, - runAsUser: 1000, - fsGroup: 1000, - }, - }); + it("adds the Localhost io_uring profile for node-24 and above, preserving pod security defaults", () => { + for (const runtime of ["node-24", "node-26", "node-30", "experimental-node-24"]) { + const podSpec = withBlockIoUringSeccompProfile(basePodSpec, runtime); - expect(podSpec).toMatchObject({ - restartPolicy: "Never", - automountServiceAccountToken: false, - securityContext: { - runAsNonRoot: true, - runAsUser: 1000, - fsGroup: 1000, - seccompProfile: { - type: "Localhost", - localhostProfile: BLOCK_IO_URING_SECCOMP_PROFILE, + expect(podSpec).toMatchObject({ + ...basePodSpec, + securityContext: { + ...basePodSpec.securityContext, + seccompProfile: { + type: "Localhost", + localhostProfile: BLOCK_IO_URING_SECCOMP_PROFILE, + }, }, - }, - }); + }); + } + }); + + it("leaves the pod spec unchanged for runtimes that do not create io_uring fds", () => { + for (const runtime of ["node", "node-22", "bun", undefined, null, ""]) { + expect(withBlockIoUringSeccompProfile(basePodSpec, runtime)).toEqual(basePodSpec); + } }); }); diff --git a/apps/supervisor/src/workloadManager/kubernetes.ts b/apps/supervisor/src/workloadManager/kubernetes.ts index 3aa9b62be66..8b26f7081f8 100644 --- a/apps/supervisor/src/workloadManager/kubernetes.ts +++ b/apps/supervisor/src/workloadManager/kubernetes.ts @@ -14,10 +14,7 @@ import { PlacementTagProcessor } from "@trigger.dev/core/v3/serverOnly"; import { env } from "../env.js"; import { type K8sApi, createK8sApi, type k8s } from "../clients/kubernetes.js"; import { getRunnerId } from "../util.js"; -import { - runtimeRequiresSeccompProfile, - withBlockIoUringSeccompProfile, -} from "./kubernetesPodSpec.js"; +import { withBlockIoUringSeccompProfile } from "./kubernetesPodSpec.js"; type ResourceQuantities = { [K in "cpu" | "memory" | "ephemeral-storage"]?: string; @@ -113,10 +110,9 @@ export class KubernetesWorkloadManager implements WorkloadManager { // The io_uring-blocking profile is only needed to keep pods checkpointable. // Skip it unless checkpointing is enabled - self-hosters don't have the // profile provisioned on their nodes, so applying it would fail pod creation. - const podSpec = - this.opts.checkpointsEnabled && runtimeRequiresSeccompProfile(opts.runtime) - ? withBlockIoUringSeccompProfile(basePodSpec) - : basePodSpec; + const podSpec = this.opts.checkpointsEnabled + ? withBlockIoUringSeccompProfile(basePodSpec, opts.runtime) + : basePodSpec; await this.k8s.core.createNamespacedPod({ namespace: this.namespace, diff --git a/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts b/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts index d47d5d7c740..32c4410ca43 100644 --- a/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts +++ b/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts @@ -9,21 +9,17 @@ export const BLOCK_IO_URING_SECCOMP_PROFILE = "profiles/block-io-uring.json"; /** * Node >= 24 always creates io_uring fds, which can't be checkpointed. Blocking * io_uring_setup makes libuv fall back to epoll. Other runtimes don't need this, - * so it's scoped to node-24+. Tolerates an "experimental-" prefix. - */ -export function runtimeRequiresSeccompProfile(runtime: string | null | undefined): boolean { - if (!runtime) return false; - const match = /^(?:experimental-)?node-(\d+)$/.exec(runtime); - return match ? Number(match[1]) >= 24 : false; -} - -/** - * Applies the io_uring-blocking seccomp profile, preserving existing - * security-context fields. Only call when runtimeRequiresSeccompProfile is true. + * so the profile is only applied for node-24+. Tolerates an "experimental-" prefix. */ export function withBlockIoUringSeccompProfile( - podSpec: Omit + podSpec: Omit, + runtime: string | null | undefined ): Omit { + const match = runtime ? /^(?:experimental-)?node-(\d+)$/.exec(runtime) : null; + if (!match || Number(match[1]) < 24) { + return podSpec; + } + return { ...podSpec, securityContext: { diff --git a/apps/supervisor/src/workloadManager/types.ts b/apps/supervisor/src/workloadManager/types.ts index 66a906568c6..4945b1382d9 100644 --- a/apps/supervisor/src/workloadManager/types.ts +++ b/apps/supervisor/src/workloadManager/types.ts @@ -16,10 +16,7 @@ export interface WorkloadManagerOptions { snapshotPollIntervalSeconds?: number; additionalEnvVars?: Record; dockerAutoremove?: boolean; - // Whether CRIU checkpoint/restore is enabled for this deployment. Only when - // checkpointing is enabled do node-24+ pods need the io_uring-blocking seccomp - // profile (io_uring FDs can't be checkpointed). Self-hosters without - // checkpointing don't need it - and don't have the profile on their nodes. + // Whether CRIU checkpoint/restore is enabled for this deployment checkpointsEnabled?: boolean; } @@ -45,8 +42,7 @@ export interface WorkloadManagerCreateOptions { projectId: string; deploymentFriendlyId: string; deploymentVersion: string; - // Canonical runtime identifier (e.g. "node", "node-22", "node-24"). Used to - // scope the io_uring-blocking seccomp profile to runtimes that require it. + // Canonical runtime identifier (e.g. "node", "node-22", "node-24") runtime?: string; runId: string; runFriendlyId: string; diff --git a/packages/core/src/v3/schemas/runEngine.ts b/packages/core/src/v3/schemas/runEngine.ts index c6a3580702c..92144364494 100644 --- a/packages/core/src/v3/schemas/runEngine.ts +++ b/packages/core/src/v3/schemas/runEngine.ts @@ -272,8 +272,7 @@ export const DequeuedMessage = z.object({ id: z.string(), friendlyId: z.string(), version: z.string(), - // Canonical runtime identifier (e.g. "node", "node-22", "node-24", "bun"). - // Consumed by orchestrators to decide per-runtime pod settings. + // Canonical runtime identifier (e.g. "node", "node-22", "node-24", "bun") runtime: z.string().optional(), }), deployment: z.object({ From fda0f49d99b6a0f6595465a39a47a2d98bb74f8b Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 15 Jul 2026 15:10:34 +0100 Subject: [PATCH 10/11] delete another comment --- apps/supervisor/src/workloadManager/kubernetes.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/supervisor/src/workloadManager/kubernetes.ts b/apps/supervisor/src/workloadManager/kubernetes.ts index 8b26f7081f8..282ac80b4fd 100644 --- a/apps/supervisor/src/workloadManager/kubernetes.ts +++ b/apps/supervisor/src/workloadManager/kubernetes.ts @@ -107,9 +107,6 @@ export class KubernetesWorkloadManager implements WorkloadManager { try { const basePodSpec = this.addPlacementTags(this.#defaultPodSpec, opts.placementTags); - // The io_uring-blocking profile is only needed to keep pods checkpointable. - // Skip it unless checkpointing is enabled - self-hosters don't have the - // profile provisioned on their nodes, so applying it would fail pod creation. const podSpec = this.opts.checkpointsEnabled ? withBlockIoUringSeccompProfile(basePodSpec, opts.runtime) : basePodSpec; From 90867d71b633aa98733853149af894b397b4d842 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 15 Jul 2026 15:21:04 +0100 Subject: [PATCH 11/11] format --- apps/supervisor/src/workloadManager/kubernetes.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/supervisor/src/workloadManager/kubernetes.test.ts b/apps/supervisor/src/workloadManager/kubernetes.test.ts index 701f063fd39..85ad3cbebff 100644 --- a/apps/supervisor/src/workloadManager/kubernetes.test.ts +++ b/apps/supervisor/src/workloadManager/kubernetes.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { BLOCK_IO_URING_SECCOMP_PROFILE, withBlockIoUringSeccompProfile } from "./kubernetesPodSpec.js"; +import { + BLOCK_IO_URING_SECCOMP_PROFILE, + withBlockIoUringSeccompProfile, +} from "./kubernetesPodSpec.js"; const basePodSpec = { restartPolicy: "Never" as const,