From 239a752f8a82b0892280faf2e6980ca419a6c6dc Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 19 May 2026 11:04:02 +0100 Subject: [PATCH 1/9] fix(sdk): forward requestOptions in triggerAndSubscribe --- packages/trigger-sdk/src/v3/shared.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index 0e6389a053c..f1884b57acc 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -2585,7 +2585,7 @@ async function triggerAndSubscribe_internal Date: Tue, 19 May 2026 11:06:47 +0100 Subject: [PATCH 2/9] fix(core): stop SSEStreamSubscription retrying permanent 4xx forever --- packages/core/src/v3/apiClient/runStream.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/src/v3/apiClient/runStream.ts b/packages/core/src/v3/apiClient/runStream.ts index b52283eae9e..bca59a82936 100644 --- a/packages/core/src/v3/apiClient/runStream.ts +++ b/packages/core/src/v3/apiClient/runStream.ts @@ -233,8 +233,10 @@ export class SSEStreamSubscription implements StreamSubscription { // reset the timer naturally. stallTimeoutMs?: number; // HTTP statuses that should NOT be retried — fail the stream - // permanently. `404` (stream gone) and `410` (session closed) - // are sensible defaults; tune per-caller for other 4xx. + // permanently. Defaults cover the permanent client-error set: + // `400` (bad request), `404` (stream gone), `409` (conflict), + // `410` (session closed), `422` (unprocessable). Tune per-caller + // for other 4xx. nonRetryableStatuses?: readonly number[]; // Optional fetch override. Used by transports that need to route // the SSE connect through a custom path (proxy, custom headers, From 241332c56c294a93548423648cc5bfe47d09ff8b Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 19 May 2026 11:15:51 +0100 Subject: [PATCH 3/9] docs(sdk): correct PublicTokenPermissions session scope docstring --- packages/trigger-sdk/src/v3/auth.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/trigger-sdk/src/v3/auth.ts b/packages/trigger-sdk/src/v3/auth.ts index 16de798b0a3..614019941db 100644 --- a/packages/trigger-sdk/src/v3/auth.ts +++ b/packages/trigger-sdk/src/v3/auth.ts @@ -74,8 +74,7 @@ type PublicTokenPermissionProperties = { * * `read:sessions:{id}` lets the bearer read both the `.out` and `.in` * channels and list runs on the session. `write:sessions:{id}` lets the - * bearer append to the session's channels. `trigger:sessions:{id}` permits - * triggering new runs on the session. + * bearer append to the session's channels and create new runs against it. */ sessions?: string | string[]; }; From cb7c6b4809a1b0d07b37b2ac9b7ca0c85eed4fc5 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 19 May 2026 11:17:10 +0100 Subject: [PATCH 4/9] fix(sdk): forward caller AbortSignal on Node 18 sessions writer --- packages/trigger-sdk/src/v3/sessions.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/trigger-sdk/src/v3/sessions.ts b/packages/trigger-sdk/src/v3/sessions.ts index 18023535f52..c9be0cc97ea 100644 --- a/packages/trigger-sdk/src/v3/sessions.ts +++ b/packages/trigger-sdk/src/v3/sessions.ts @@ -451,9 +451,26 @@ export class SessionOutputChannel { const readableStreamSource = ensureReadableStream(value); const abortController = new AbortController(); - const combinedSignal = options?.signal - ? AbortSignal.any?.([options.signal, abortController.signal]) ?? abortController.signal - : abortController.signal; + // `AbortSignal.any` lands in Node 20.3; the SDK still supports Node + // 18.20+. On older runtimes fall back to wiring `options.signal` into + // `abortController` manually so caller-driven cancellation propagates. + let combinedSignal: AbortSignal = abortController.signal; + if (options?.signal) { + if (typeof AbortSignal.any === "function") { + combinedSignal = AbortSignal.any([options.signal, abortController.signal]); + } else { + const callerSignal = options.signal; + if (callerSignal.aborted) { + abortController.abort(callerSignal.reason); + } else { + callerSignal.addEventListener( + "abort", + () => abortController.abort(callerSignal.reason), + { once: true } + ); + } + } + } // Resolve the init promise eagerly so we can capture which one this // writer uses for reactive invalidation below. From 217089467b37f4a8d82236766d6f699fb7d0934d Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 19 May 2026 12:00:01 +0100 Subject: [PATCH 5/9] fix(sdk): pass requestOptions in correct positional slot of triggerTask --- packages/trigger-sdk/src/v3/shared.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index f1884b57acc..b8e1874b5be 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -2585,7 +2585,8 @@ async function triggerAndSubscribe_internal Date: Tue, 19 May 2026 12:24:24 +0100 Subject: [PATCH 6/9] fix(core): widen SSEStreamSubscription default nonRetryableStatuses --- packages/core/src/v3/apiClient/runStream.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/src/v3/apiClient/runStream.ts b/packages/core/src/v3/apiClient/runStream.ts index bca59a82936..4b60bb410fa 100644 --- a/packages/core/src/v3/apiClient/runStream.ts +++ b/packages/core/src/v3/apiClient/runStream.ts @@ -251,7 +251,9 @@ export class SSEStreamSubscription implements StreamSubscription { this.retryJitter = options.retryJitter ?? 0.5; this.fetchTimeoutMs = options.fetchTimeoutMs ?? 30_000; this.stallTimeoutMs = options.stallTimeoutMs ?? 0; - this.nonRetryableStatuses = new Set(options.nonRetryableStatuses ?? [404, 410]); + this.nonRetryableStatuses = new Set( + options.nonRetryableStatuses ?? [400, 404, 409, 410, 422] + ); } /** From 4a1eaba75a0af00ca16db8b4a8899910ba0067d8 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 19 May 2026 12:33:57 +0100 Subject: [PATCH 7/9] fix(sdk): TriggerChatTransport handover fail-fast + dispose aborts streams --- packages/trigger-sdk/src/v3/chat.ts | 30 +++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/trigger-sdk/src/v3/chat.ts b/packages/trigger-sdk/src/v3/chat.ts index 2aefc2bb800..aaa3871e34a 100644 --- a/packages/trigger-sdk/src/v3/chat.ts +++ b/packages/trigger-sdk/src/v3/chat.ts @@ -671,17 +671,23 @@ export class TriggerChatTransport implements ChatTransport { } // Hydrate session state from response headers so subsequent turns - // skip the endpoint and write directly to session.in. + // skip the endpoint and write directly to session.in. Failing fast + // when the header is missing avoids a quiet degraded state where + // every later turn re-runs the handover route instead of taking + // the slim-wire path. const accessToken = response.headers.get("X-Trigger-Chat-Access-Token"); const chatId = args.chatId; - if (accessToken) { - const state: ChatSessionState = { - publicAccessToken: accessToken, - isStreaming: true, - }; - this.sessions.set(chatId, state); - this.notifySessionChange(chatId, state); + if (!accessToken) { + throw new Error( + "chat.handover response is missing the X-Trigger-Chat-Access-Token header. chat.agent's handover endpoint must echo the session PAT so the transport can hydrate." + ); } + const state: ChatSessionState = { + publicAccessToken: accessToken, + isStreaming: true, + }; + this.sessions.set(chatId, state); + this.notifySessionChange(chatId, state); // Filter the parsed UIMessage stream: // - Drop control chunks (`trigger:turn-complete`, @@ -953,6 +959,14 @@ export class TriggerChatTransport implements ChatTransport { this.coordinator?.removeMessagesListener(fn); } dispose(): void { + // Tear down any open session.out subscriptions before the coordinator + // goes away. Otherwise controllers in `activeStreams` keep reading + // until they time out, leaking network and memory on every + // unmount/navigation. + for (const controller of this.activeStreams.values()) { + controller.abort(); + } + this.activeStreams.clear(); this.coordinator?.dispose(); this.coordinator = null; } From 385c2846f20f666aaa617b363486d5212ce1958f Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 19 May 2026 12:35:37 +0100 Subject: [PATCH 8/9] fix(build): remove secureExec extension --- packages/build/package.json | 17 +- packages/build/src/extensions/secureExec.ts | 172 -------------------- 2 files changed, 1 insertion(+), 188 deletions(-) delete mode 100644 packages/build/src/extensions/secureExec.ts diff --git a/packages/build/package.json b/packages/build/package.json index 8d7bf6daf3f..206a80b89da 100644 --- a/packages/build/package.json +++ b/packages/build/package.json @@ -31,8 +31,7 @@ "./extensions/typescript": "./src/extensions/typescript.ts", "./extensions/puppeteer": "./src/extensions/puppeteer.ts", "./extensions/playwright": "./src/extensions/playwright.ts", - "./extensions/lightpanda": "./src/extensions/lightpanda.ts", - "./extensions/secureExec": "./src/extensions/secureExec.ts" + "./extensions/lightpanda": "./src/extensions/lightpanda.ts" }, "sourceDialects": [ "@triggerdotdev/source" @@ -66,9 +65,6 @@ ], "extensions/lightpanda": [ "dist/commonjs/extensions/lightpanda.d.ts" - ], - "extensions/secureExec": [ - "dist/commonjs/extensions/secureExec.d.ts" ] } }, @@ -211,17 +207,6 @@ "types": "./dist/commonjs/extensions/lightpanda.d.ts", "default": "./dist/commonjs/extensions/lightpanda.js" } - }, - "./extensions/secureExec": { - "import": { - "@triggerdotdev/source": "./src/extensions/secureExec.ts", - "types": "./dist/esm/extensions/secureExec.d.ts", - "default": "./dist/esm/extensions/secureExec.js" - }, - "require": { - "types": "./dist/commonjs/extensions/secureExec.d.ts", - "default": "./dist/commonjs/extensions/secureExec.js" - } } }, "main": "./dist/commonjs/index.js", diff --git a/packages/build/src/extensions/secureExec.ts b/packages/build/src/extensions/secureExec.ts deleted file mode 100644 index 808bc666501..00000000000 --- a/packages/build/src/extensions/secureExec.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { BuildTarget } from "@trigger.dev/core/v3"; -import { BuildManifest } from "@trigger.dev/core/v3/schemas"; -import { BuildContext, BuildExtension } from "@trigger.dev/core/v3/build"; -import { dirname, resolve, join } from "node:path"; -import { readFileSync } from "node:fs"; -import { createRequire } from "node:module"; -import { readPackageJSON } from "pkg-types"; - -export type SecureExecOptions = { - /** - * Packages available inside the sandbox at runtime. - * - * These are `require()`'d inside the V8 isolate at runtime — the bundler - * never sees them statically. They are marked external and installed as - * deploy dependencies. - * - * @example - * ```ts - * secureExec({ packages: ["jszip", "lodash"] }) - * ``` - */ - packages?: string[]; -}; - -/** - * Build extension for [secure-exec](https://secureexec.dev) — run untrusted - * JavaScript/TypeScript in V8 isolates with configurable permissions. - * - * Handles the esbuild workarounds needed for secure-exec's runtime - * `require.resolve` calls, native binaries, and module-scope resolution. - * - * @example - * ```ts - * import { secureExec } from "@trigger.dev/build/extensions/secureExec"; - * - * export default defineConfig({ - * build: { - * extensions: [secureExec()], - * }, - * }); - * ``` - */ -export function secureExec(options?: SecureExecOptions): BuildExtension { - return new SecureExecExtension(options ?? {}); -} - -class SecureExecExtension implements BuildExtension { - public readonly name = "SecureExecExtension"; - - private userPackages: string[]; - - constructor(options: SecureExecOptions) { - this.userPackages = options.packages ?? []; - } - - externalsForTarget(_target: BuildTarget) { - return [ - // esbuild must not be bundled — it locates its native binary via a - // relative path from its JS API entry point. secure-exec uses esbuild - // at runtime to bundle polyfills for sandbox code. - "esbuild", - // User-specified packages are require()'d inside the V8 sandbox at - // runtime — the bundler never sees them statically. - ...this.userPackages, - ]; - } - - onBuildStart(context: BuildContext) { - context.logger.debug(`Adding ${this.name} esbuild plugins`); - - // Plugin 1: Replace node-stdlib-browser with pre-resolved paths. - // - // Trigger's ESM shim anchors require.resolve() to the chunk path, so - // node-stdlib-browser's runtime require.resolve("./mock/empty.js") breaks. - // Fix: load the real node-stdlib-browser at build time (where require.resolve - // works), capture the resolved path map, and inline it as a static export. - const workingDir = context.workingDir; - context.registerPlugin({ - name: "secure-exec-stdlib-resolver", - setup(build) { - build.onResolve({ filter: /^node-stdlib-browser$/ }, () => ({ - path: "node-stdlib-browser", - namespace: "secure-exec-nsb-resolved", - })); - build.onLoad({ filter: /.*/, namespace: "secure-exec-nsb-resolved" }, () => { - const buildRequire = createRequire(join(workingDir, "package.json")); - const resolved = buildRequire("node-stdlib-browser"); - return { - contents: `export default ${JSON.stringify(resolved)};`, - loader: "js", - }; - }); - }, - }); - - // Plugin 2: Inline bridge.js at build time. - // - // bridge-loader.js in @secure-exec/node(js) uses __dirname and - // require.resolve("@secure-exec/core") at module scope to locate - // dist/bridge.js on disk. This fails in Trigger's bundled output. - // Fix: read bridge.js content at build time and inline it as a - // string literal so no runtime filesystem resolution is needed. - // - context.registerPlugin({ - name: "secure-exec-bridge-inline", - setup(build) { - build.onLoad( - { filter: /[\\/]@secure-exec[\\/]node[\\/]dist[\\/]bridge-loader\.js$/ }, - (args) => { - try { - const buildRequire = createRequire(args.path); - const coreEntry = buildRequire.resolve("@secure-exec/core"); - const coreRoot = resolve(dirname(coreEntry), ".."); - const bridgeCode = readFileSync(join(coreRoot, "dist", "bridge.js"), "utf8"); - - return { - contents: [ - `import { getIsolateRuntimeSource } from "@secure-exec/core";`, - `const bridgeCodeCache = ${JSON.stringify(bridgeCode)};`, - `export function getRawBridgeCode() { return bridgeCodeCache; }`, - `export function getBridgeAttachCode() { return getIsolateRuntimeSource("bridgeAttach"); }`, - ].join("\n"), - loader: "js", - }; - } catch { - // If we can't inline the bridge, let the normal loader handle it. - return undefined; - } - } - ); - }, - }); - } - - async onBuildComplete(context: BuildContext, _manifest: BuildManifest) { - if (context.target === "dev") { - return; - } - - context.logger.debug(`Adding ${this.name} deploy dependencies`); - - const dependencies: Record = {}; - - // Resolve versions for user-specified sandbox packages - for (const pkg of this.userPackages) { - try { - const modulePath = await context.resolvePath(pkg); - if (!modulePath) { - dependencies[pkg] = "latest"; - continue; - } - - const packageJSON = await readPackageJSON(dirname(modulePath)); - dependencies[pkg] = packageJSON.version ?? "latest"; - } catch { - context.logger.warn( - `Could not resolve version for sandbox package ${pkg}, defaulting to latest` - ); - dependencies[pkg] = "latest"; - } - } - - context.addLayer({ - id: "secureExec", - dependencies, - image: { - // isolated-vm requires native compilation tools - pkgs: ["python3", "make", "g++"], - }, - }); - } -} From 991d1d68e8887a6a0bd54797a48813518a9cc878 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 19 May 2026 14:37:08 +0100 Subject: [PATCH 9/9] fix(sdk): unregister caller abort listener after sessions writer completes --- packages/trigger-sdk/src/v3/sessions.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/trigger-sdk/src/v3/sessions.ts b/packages/trigger-sdk/src/v3/sessions.ts index c9be0cc97ea..ea3ebd8d937 100644 --- a/packages/trigger-sdk/src/v3/sessions.ts +++ b/packages/trigger-sdk/src/v3/sessions.ts @@ -455,6 +455,11 @@ export class SessionOutputChannel { // 18.20+. On older runtimes fall back to wiring `options.signal` into // `abortController` manually so caller-driven cancellation propagates. let combinedSignal: AbortSignal = abortController.signal; + // Set in the Node 18 fallback path so the caller's `signal.addEventListener` + // registration can be cleared once the stream finishes. Without this, a + // long-lived caller signal (e.g. one reused across many `writer()` calls) + // accumulates listeners on every completed turn. + let removeCallerAbortListener: (() => void) | undefined; if (options?.signal) { if (typeof AbortSignal.any === "function") { combinedSignal = AbortSignal.any([options.signal, abortController.signal]); @@ -463,11 +468,10 @@ export class SessionOutputChannel { if (callerSignal.aborted) { abortController.abort(callerSignal.reason); } else { - callerSignal.addEventListener( - "abort", - () => abortController.abort(callerSignal.reason), - { once: true } - ); + const onCallerAbort = () => abortController.abort(callerSignal.reason); + callerSignal.addEventListener("abort", onCallerAbort, { once: true }); + removeCallerAbortListener = () => + callerSignal.removeEventListener("abort", onCallerAbort); } } } @@ -516,9 +520,11 @@ export class SessionOutputChannel { // from surfacing as unhandled. instance.wait().then( () => { + removeCallerAbortListener?.(); span.end(); }, () => { + removeCallerAbortListener?.(); if (this.#initPromise === writerInitPromise) { this.#initPromise = undefined; } @@ -533,6 +539,7 @@ export class SessionOutputChannel { }, }; } catch (error) { + removeCallerAbortListener?.(); if (error instanceof Error && error.name === "AbortError") { span.end(); throw error;