From 0cfaa4148619ff782e04d5f1c5532a981c2a0878 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 2 Nov 2026 12:00:05 -0800 Subject: [PATCH 1/6] feat(trigger): reach AWS from trigger.dev runs through workload identity Trigger.dev runs reached S3, AppConfig, CloudWatch and hosted Bedrock with the app IAM user's static keys. With TRIGGER_AWS_ROLE_ARN set, Sim's own AWS clients now use temporary credentials for that role, exchanged for the run's Trigger.dev workload-identity token (SDK 4.8.3). One session serves the worker process, refreshed at each attempt's start so the post-run metrics flush keeps valid credentials. Static keys and the default chain are unchanged for ECS and self-hosted deployments. --- .github/workflows/ci.yml | 4 +- .github/workflows/trigger-dev.yml | 4 +- apps/sim/lib/core/config/appconfig.ts | 4 +- apps/sim/lib/core/config/aws.ts | 124 +++++++++++++++++--- apps/sim/lib/core/config/env.ts | 1 + apps/sim/lib/monitoring/metrics.ts | 14 ++- apps/sim/lib/uploads/core/setup.server.ts | 3 +- apps/sim/lib/uploads/providers/s3/client.ts | 4 +- apps/sim/package.json | 6 +- apps/sim/providers/bedrock/index.ts | 13 +- apps/sim/trigger.config.ts | 8 +- 11 files changed, 147 insertions(+), 38 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b032f2f7a72..604fcf09072 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -292,7 +292,7 @@ jobs: if [ -n "$TRIGGER_BRANCH" ]; then TARGET_ARGS+=(--branch "$TRIGGER_BRANCH") fi - bunx trigger.dev@4.8.2 deploy "${TARGET_ARGS[@]}" --skip-promotion + bunx trigger.dev@4.8.3 deploy "${TARGET_ARGS[@]}" --skip-promotion - name: Validate deployment version output env: @@ -643,7 +643,7 @@ jobs: if [ -n "$TRIGGER_BRANCH" ]; then TARGET_ARGS+=(--branch "$TRIGGER_BRANCH") fi - bunx trigger.dev@4.8.2 promote "$VERSION" "${TARGET_ARGS[@]}" + bunx trigger.dev@4.8.3 promote "$VERSION" "${TARGET_ARGS[@]}" # Production only, once the new app serves traffic: submit the library and comparison URLs this # push changed to IndexNow. Best effort: nothing depends on it, and a failure never fails the run. diff --git a/.github/workflows/trigger-dev.yml b/.github/workflows/trigger-dev.yml index 4ad1d348200..c34d6115764 100644 --- a/.github/workflows/trigger-dev.yml +++ b/.github/workflows/trigger-dev.yml @@ -41,7 +41,7 @@ jobs: set -euo pipefail : "${TRIGGER_ACCESS_TOKEN:?TRIGGER_ACCESS_TOKEN_PREVIEW or TRIGGER_ACCESS_TOKEN must be configured}" : "${TRIGGER_PROJECT_ID:?TRIGGER_PROJECT_ID must be configured}" - bunx trigger.dev@4.8.2 deploy --env preview --branch dev-sim --skip-promotion + bunx trigger.dev@4.8.3 deploy --env preview --branch dev-sim --skip-promotion - name: Promote current dev preview working-directory: ./apps/sim @@ -61,4 +61,4 @@ jobs: echo "::notice::Skipping superseded dev task version $VERSION" exit 0 fi - bunx trigger.dev@4.8.2 promote "$VERSION" --env preview --branch dev-sim + bunx trigger.dev@4.8.3 promote "$VERSION" --env preview --branch dev-sim diff --git a/apps/sim/lib/core/config/appconfig.ts b/apps/sim/lib/core/config/appconfig.ts index 6046ea1a9ef..da35e356bb6 100644 --- a/apps/sim/lib/core/config/appconfig.ts +++ b/apps/sim/lib/core/config/appconfig.ts @@ -8,7 +8,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { toRecordOrNull } from '@sim/utils/object' import { LRUCache } from 'lru-cache' -import { getAwsCredentialsFromEnv } from '@/lib/core/config/aws' +import { getAwsCredentials } from '@/lib/core/config/aws' import { env } from '@/lib/core/config/env' const logger = createLogger('AppConfig') @@ -121,7 +121,7 @@ function getClient(): AppConfigDataClient { if (!client) { client = new AppConfigDataClient({ region: env.AWS_REGION, - credentials: getAwsCredentialsFromEnv(), + credentials: getAwsCredentials(), }) } return client diff --git a/apps/sim/lib/core/config/aws.ts b/apps/sim/lib/core/config/aws.ts index 13f5570a2e6..c8dedcd3794 100644 --- a/apps/sim/lib/core/config/aws.ts +++ b/apps/sim/lib/core/config/aws.ts @@ -1,24 +1,120 @@ +import { createLogger } from '@sim/logger' +import { type AWSCredentials, awsCredentialProvider } from '@trigger.dev/sdk/oidc/aws' import { env } from '@/lib/core/config/env' -interface AwsCredentials { +const logger = createLogger('AwsCredentials') + +interface StaticAwsCredentials { accessKeyId: string secretAccessKey: string } +type AwsCredentialsProvider = () => Promise + +/** + * Refresh workload credentials this long before they expire. It must exceed the AWS SDK's own + * five-minute refresh window, or a client keeps asking for credentials this module still + * considers fresh. + */ +const REFRESH_BEFORE_EXPIRY_MS = 10 * 60_000 + +/** + * At the start of a run, refresh workload credentials that expire within this window. A token can + * only be minted while an attempt is executing, and work a run leaves behind (the hosted-key + * metrics flush) still calls AWS after the attempt ends. + */ +const RUN_START_MIN_REMAINING_MS = 30 * 60_000 + +/** + * One AWS session for the whole worker process, not one per run. Every run in a deployment + * assumes the same role, and Sim's AWS clients are process-wide, so a run-named session would + * attribute later runs' calls to the run that minted it. + */ +const WORKLOAD_ROLE_SESSION_NAME = 'sim-trigger-worker' + +let workloadProvider: AwsCredentialsProvider | null = null +let currentWorkloadCredentials: AWSCredentials | null = null +let pendingWorkloadCredentials: Promise | null = null + +function getWorkloadProvider(roleArn: string): AwsCredentialsProvider { + if (!workloadProvider) { + if (!env.AWS_REGION) { + throw new Error('TRIGGER_AWS_ROLE_ARN is set but AWS_REGION is not; set both.') + } + workloadProvider = awsCredentialProvider({ + roleArn, + region: env.AWS_REGION, + roleSessionName: WORKLOAD_ROLE_SESSION_NAME, + }) + } + return workloadProvider +} + +/** + * The cached workload credentials while they have more than `minRemainingMs` left, otherwise a + * fresh exchange. Concurrent callers share one exchange. + */ +function resolveWorkloadCredentials( + roleArn: string, + minRemainingMs: number +): Promise { + const current = currentWorkloadCredentials + if (current && current.expiration.getTime() - Date.now() > minRemainingMs) { + return Promise.resolve(current) + } + if (!pendingWorkloadCredentials) { + pendingWorkloadCredentials = getWorkloadProvider(roleArn)() + .then((credentials) => { + currentWorkloadCredentials = credentials + return credentials + }) + .finally(() => { + pendingWorkloadCredentials = null + }) + } + return pendingWorkloadCredentials +} + /** - * Explicit AWS credentials from the environment, or `undefined` to defer to the - * default AWS provider chain (the ECS task role in our deployments). + * Credentials for Sim's own AWS clients: file storage (S3), AppConfig, hosted-key metrics and + * hosted Bedrock. Never for a user's integration, which brings its own keys. * - * Shared by every AWS SDK client (S3, AppConfig, …) so credential resolution is - * identical everywhere: explicit keys when both `AWS_ACCESS_KEY_ID` and - * `AWS_SECRET_ACCESS_KEY` are set (self-hosted, trigger.dev workers), otherwise - * the instance/task role. + * - `TRIGGER_AWS_ROLE_ARN` set (deployed Trigger.dev runs): temporary credentials for that role, + * exchanged for the run's Trigger.dev workload-identity token. Takes precedence over static + * keys, so a failed exchange surfaces instead of silently falling back to a stored key. + * - `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` set: those keys (self-hosted deployments). + * - Neither: `undefined`, deferring to the AWS default provider chain (the ECS task role). + */ +export function getAwsCredentials(): StaticAwsCredentials | AwsCredentialsProvider | undefined { + const roleArn = env.TRIGGER_AWS_ROLE_ARN + if (roleArn) { + return () => resolveWorkloadCredentials(roleArn, REFRESH_BEFORE_EXPIRY_MS) + } + if (env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY) { + return { + accessKeyId: env.AWS_ACCESS_KEY_ID, + secretAccessKey: env.AWS_SECRET_ACCESS_KEY, + } + } + return undefined +} + +/** Whether Sim's own AWS clients have credentials of their own rather than the default chain. */ +export function hasExplicitAwsCredentials(): boolean { + return Boolean(env.TRIGGER_AWS_ROLE_ARN || (env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY)) +} + +/** + * Refreshes workload credentials at the start of a Trigger.dev run, while a token can be minted, + * so they outlive the attempt. A no-op outside workload identity. Never throws: a failure is + * logged here, and the run's first AWS call retries the exchange and fails with the cause. */ -export function getAwsCredentialsFromEnv(): AwsCredentials | undefined { - return env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY - ? { - accessKeyId: env.AWS_ACCESS_KEY_ID, - secretAccessKey: env.AWS_SECRET_ACCESS_KEY, - } - : undefined +export async function refreshAwsCredentialsForRun(): Promise { + const roleArn = env.TRIGGER_AWS_ROLE_ARN + if (!roleArn) return + try { + await resolveWorkloadCredentials(roleArn, RUN_START_MIN_REMAINING_MS) + } catch (error) { + logger.error('Could not exchange the workload-identity token for AWS credentials', { error }) + } } diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 5aa643f904a..1af9bdf88ba 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -364,6 +364,7 @@ export const env = createEnv({ AWS_REGION: z.string().optional(), // AWS region for S3 buckets AWS_ACCESS_KEY_ID: z.string().optional(), // AWS access key ID AWS_SECRET_ACCESS_KEY: z.string().optional(), // AWS secret access key + TRIGGER_AWS_ROLE_ARN: z.string().optional(), // Role Trigger.dev runs assume through workload identity, instead of static keys S3_BUCKET_NAME: z.string().optional(), // S3 bucket for general file storage S3_KB_BUCKET_NAME: z.string().optional(), // S3 bucket for knowledge base files S3_EXECUTION_FILES_BUCKET_NAME: z.string().optional(), // S3 bucket for workflow execution files diff --git a/apps/sim/lib/monitoring/metrics.ts b/apps/sim/lib/monitoring/metrics.ts index 4f6f980bd39..85fce7fceba 100644 --- a/apps/sim/lib/monitoring/metrics.ts +++ b/apps/sim/lib/monitoring/metrics.ts @@ -30,6 +30,7 @@ import { } from '@aws-sdk/client-cloudwatch' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { getAwsCredentials, hasExplicitAwsCredentials } from '@/lib/core/config/aws' const logger = createLogger('HostedKeyMetrics') @@ -44,10 +45,10 @@ type QueueReason = 'actor_requests' | 'dimension' | 'queue_position' /** `metering` marks a provider call that succeeded but could not be priced. */ type FailureReason = 'rate_limited' | 'auth' | 'other' | 'metering' -// Deployed envs (app + trigger worker) carry static AWS creds; local dev does -// not. No creds → no-op, so recorders stay always-safe to call (same contract -// as the previous no-op-meter behavior). -const ENABLED = Boolean(process.env.AWS_ACCESS_KEY_ID) +// Deployed envs (app + trigger worker) carry AWS credentials of their own; +// local dev does not. No creds → no-op, so recorders stay always-safe to call +// (same contract as the previous no-op-meter behavior). +const ENABLED = hasExplicitAwsCredentials() const ENVIRONMENT = process.env.OTEL_DEPLOYMENT_ENVIRONMENT || @@ -63,7 +64,10 @@ let handlersRegistered = false function getClient(): CloudWatchClient { if (!client) { - client = new CloudWatchClient({ region: process.env.AWS_REGION || 'us-east-1' }) + client = new CloudWatchClient({ + region: process.env.AWS_REGION || 'us-east-1', + credentials: getAwsCredentials(), + }) } return client } diff --git a/apps/sim/lib/uploads/core/setup.server.ts b/apps/sim/lib/uploads/core/setup.server.ts index b446382e87d..8f0a2217717 100644 --- a/apps/sim/lib/uploads/core/setup.server.ts +++ b/apps/sim/lib/uploads/core/setup.server.ts @@ -2,6 +2,7 @@ import { existsSync } from 'fs' import { mkdir } from 'fs/promises' import path, { join } from 'path' import { createLogger } from '@sim/logger' +import { hasExplicitAwsCredentials } from '@/lib/core/config/aws' import { env } from '@/lib/core/config/env' import { getStorageProvider, @@ -88,7 +89,7 @@ if (typeof process !== 'undefined') { if (!env.S3_BUCKET_NAME || !env.AWS_REGION) { logger.warn('S3 storage configuration is incomplete') logger.warn('Set S3_BUCKET_NAME and AWS_REGION for S3 storage') - } else if (!env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY) { + } else if (!hasExplicitAwsCredentials()) { logger.warn('AWS credentials are not set in environment variables') logger.warn('Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY for S3 storage') } else { diff --git a/apps/sim/lib/uploads/providers/s3/client.ts b/apps/sim/lib/uploads/providers/s3/client.ts index 4c6d60a5dc4..a0b38ea57c5 100644 --- a/apps/sim/lib/uploads/providers/s3/client.ts +++ b/apps/sim/lib/uploads/providers/s3/client.ts @@ -16,7 +16,7 @@ import { import { getSignedUrl } from '@aws-sdk/s3-request-presigner' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { getAwsCredentialsFromEnv } from '@/lib/core/config/aws' +import { getAwsCredentials } from '@/lib/core/config/aws' import { assertKnownSizeWithinLimit, readNodeStreamToBufferWithLimit, @@ -62,7 +62,7 @@ export function getS3Client(): S3Client { region, endpoint: S3_CONFIG.endpoint, forcePathStyle: S3_CONFIG.forcePathStyle, - credentials: getAwsCredentialsFromEnv(), + credentials: getAwsCredentials(), }) return _s3Client diff --git a/apps/sim/package.json b/apps/sim/package.json index afbc2dc9748..84241bd6f36 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -177,8 +177,8 @@ "@tiptap/starter-kit": "3.31.4", "@tiptap/suggestion": "3.31.4", "@tiptap/y-tiptap": "3.0.7", - "@trigger.dev/core": "4.8.2", - "@trigger.dev/sdk": "4.8.2", + "@trigger.dev/core": "4.8.3", + "@trigger.dev/sdk": "4.8.3", "@typescript/typescript6": "^6.0.2", "@xterm/addon-fit": "0.11.0", "@xterm/addon-unicode11": "0.9.0", @@ -298,7 +298,7 @@ "@tailwindcss/postcss": "^4.3.3", "@tailwindcss/typography": "0.5.20", "@testing-library/jest-dom": "^6.10.0", - "@trigger.dev/build": "4.8.2", + "@trigger.dev/build": "4.8.3", "@types/archiver": "8.0.0", "@types/busboy": "1.5.4", "@types/chrome": "0.3.6", diff --git a/apps/sim/providers/bedrock/index.ts b/apps/sim/providers/bedrock/index.ts index 7b7f19585a6..26f79b5977a 100644 --- a/apps/sim/providers/bedrock/index.ts +++ b/apps/sim/providers/bedrock/index.ts @@ -15,6 +15,7 @@ import { import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' +import { getAwsCredentials } from '@/lib/core/config/aws' import { validateAwsRegion } from '@/lib/core/security/input-validation' import type { IterationToolCall, NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' @@ -158,12 +159,12 @@ export const bedrockProvider: ProviderConfig = { ) } - const clientConfig: BedrockRuntimeClientConfig = { region } - if (request.bedrockAccessKeyId && request.bedrockSecretKey) { - clientConfig.credentials = { - accessKeyId: request.bedrockAccessKeyId, - secretAccessKey: request.bedrockSecretKey, - } + const clientConfig: BedrockRuntimeClientConfig = { + region, + credentials: + request.bedrockAccessKeyId && request.bedrockSecretKey + ? { accessKeyId: request.bedrockAccessKeyId, secretAccessKey: request.bedrockSecretKey } + : getAwsCredentials(), } // Key on the full credential (access key id + secret) so a corrected secret diff --git a/apps/sim/trigger.config.ts b/apps/sim/trigger.config.ts index 02107306cd9..eebf9fa1217 100644 --- a/apps/sim/trigger.config.ts +++ b/apps/sim/trigger.config.ts @@ -162,6 +162,11 @@ export default defineConfig({ * subscribe, and they are a minority of runs, so that connection is warmed * on intent at the execution entry point instead. * + * Also refreshes workload-identity AWS credentials, alongside the Redis + * warm-up, while the attempt is executing and a token can still be minted, + * so work a run leaves behind (the hosted-key metrics flush) has credentials + * after the attempt ends. Never throws, like the warm-up. + * * @see https://trigger.dev/docs/config/config-file#lifecycle-functions */ init: async () => { @@ -173,7 +178,8 @@ export default defineConfig({ const { reportAccountTokenSealing } = await import('@sim/db/sealed-text') reportAccountTokenSealing() const { warmRedisConnection } = await import('./lib/core/config/redis') - await warmRedisConnection() + const { refreshAwsCredentialsForRun } = await import('./lib/core/config/aws') + await Promise.all([warmRedisConnection(), refreshAwsCredentialsForRun()]) }, ...(grafanaTelemetry ? { telemetry: grafanaTelemetry } : {}), build: { From d999ce20c95bdf677e21b7656be0e933530b64e3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 2 Nov 2026 12:17:13 -0800 Subject: [PATCH 2/6] chore(deps): allow trigger.dev 4.8.3 ahead of the release-age window 4.8.3 is the first release with workload identity. The exclusion is temporary and comes out once it clears the 7-day threshold on 2026-11-06. Hosted-key metrics now key off Sim Cloud itself instead of the presence of static AWS keys, so removing those keys never silences them. --- apps/sim/lib/monitoring/metrics.ts | 11 ++++++----- bun.lock | 12 ++++++------ bunfig.toml | 6 ++++-- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/apps/sim/lib/monitoring/metrics.ts b/apps/sim/lib/monitoring/metrics.ts index 85fce7fceba..9639078773f 100644 --- a/apps/sim/lib/monitoring/metrics.ts +++ b/apps/sim/lib/monitoring/metrics.ts @@ -30,7 +30,8 @@ import { } from '@aws-sdk/client-cloudwatch' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { getAwsCredentials, hasExplicitAwsCredentials } from '@/lib/core/config/aws' +import { getAwsCredentials } from '@/lib/core/config/aws' +import { isHosted } from '@/lib/core/config/env-flags' const logger = createLogger('HostedKeyMetrics') @@ -45,10 +46,10 @@ type QueueReason = 'actor_requests' | 'dimension' | 'queue_position' /** `metering` marks a provider call that succeeded but could not be priced. */ type FailureReason = 'rate_limited' | 'auth' | 'other' | 'metering' -// Deployed envs (app + trigger worker) carry AWS credentials of their own; -// local dev does not. No creds → no-op, so recorders stay always-safe to call -// (same contract as the previous no-op-meter behavior). -const ENABLED = hasExplicitAwsCredentials() +// Hosted keys exist only on Sim Cloud, whose app and workers can always reach +// CloudWatch (task role or workload identity). Elsewhere recorders are no-ops, +// so they stay always-safe to call. +const ENABLED = isHosted const ENVIRONMENT = process.env.OTEL_DEPLOYMENT_ENVIRONMENT || diff --git a/bun.lock b/bun.lock index a10ed775525..4076326f666 100644 --- a/bun.lock +++ b/bun.lock @@ -335,8 +335,8 @@ "@tiptap/starter-kit": "3.31.4", "@tiptap/suggestion": "3.31.4", "@tiptap/y-tiptap": "3.0.7", - "@trigger.dev/core": "4.8.2", - "@trigger.dev/sdk": "4.8.2", + "@trigger.dev/core": "4.8.3", + "@trigger.dev/sdk": "4.8.3", "@typescript/typescript6": "^6.0.2", "@xterm/addon-fit": "0.11.0", "@xterm/addon-unicode11": "0.9.0", @@ -455,7 +455,7 @@ "@tailwindcss/postcss": "^4.3.3", "@tailwindcss/typography": "0.5.20", "@testing-library/jest-dom": "^6.10.0", - "@trigger.dev/build": "4.8.2", + "@trigger.dev/build": "4.8.3", "@types/archiver": "8.0.0", "@types/busboy": "1.5.4", "@types/chrome": "0.3.6", @@ -2236,11 +2236,11 @@ "@tootallnate/once": ["@tootallnate/once@2.0.1", "", {}, "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ=="], - "@trigger.dev/build": ["@trigger.dev/build@4.8.2", "", { "dependencies": { "@prisma/config": "^6.10.0", "@trigger.dev/core": "4.8.2", "mlly": "^1.7.1", "pkg-types": "^1.1.3", "tinyglobby": "^0.2.2", "tsconfck": "3.1.3" }, "peerDependencies": { "@typescript/typescript6": "^6.0.0", "typescript": ">=5.0.0" }, "optionalPeers": ["@typescript/typescript6", "typescript"] }, "sha512-mxyisGjBxJPqrCuoe3sqlTgQ7Oc1mgMbUkxzp6uXQX1maPxtbYUkFp/6nPEargc3ul331jxSP8smtatFqtSy5g=="], + "@trigger.dev/build": ["@trigger.dev/build@4.8.3", "", { "dependencies": { "@prisma/config": "^6.10.0", "@trigger.dev/core": "4.8.3", "mlly": "^1.7.1", "pkg-types": "^1.1.3", "tinyglobby": "^0.2.2", "tsconfck": "3.1.3" }, "peerDependencies": { "@typescript/typescript6": "^6.0.0", "typescript": ">=5.0.0" }, "optionalPeers": ["@typescript/typescript6", "typescript"] }, "sha512-4KohHZdx0urxL96zisBAqvL4J4uKZ5u4rejWYyE8UkxDdOviR1YD3nxVmbYN4lWnpHEGxue116ZZrRoZr0+1MQ=="], - "@trigger.dev/core": ["@trigger.dev/core@4.8.2", "", { "dependencies": { "@bugsnag/cuid": "^3.1.1", "@electric-sql/client": "1.0.14", "@google-cloud/precise-date": "^4.0.0", "@jsonhero/path": "^1.0.21", "@opentelemetry/api": "1.9.1", "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/exporter-logs-otlp-http": "0.219.0", "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", "@opentelemetry/exporter-trace-otlp-http": "0.219.0", "@opentelemetry/host-metrics": "^0.38.3", "@opentelemetry/instrumentation": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/sdk-trace-node": "2.8.0", "@s2-dev/streamstore": "0.25.0", "dequal": "^2.0.3", "eventsource": "^3.0.5", "eventsource-parser": "^3.0.0", "humanize-duration": "^3.27.3", "jose": "^5.4.0", "nanoid": "3.3.18", "prom-client": "^15.1.0", "socket.io-client": "4.8.4", "std-env": "^3.8.1", "tinyexec": "^0.3.2", "uncrypto": "^0.1.3", "zod": "^3.25.56 || ^4.0.0", "zod-validation-error": "^5.0.0" } }, "sha512-wRD259+2Bxz78ULW733xb8V36DAUnA9hkj/zIkwMw6phf5hq1j/KHHT6xpSjiCzf5h/xloLhDxP8L7kyqSIAvw=="], + "@trigger.dev/core": ["@trigger.dev/core@4.8.3", "", { "dependencies": { "@bugsnag/cuid": "^3.1.1", "@electric-sql/client": "1.0.14", "@google-cloud/precise-date": "^4.0.0", "@jsonhero/path": "^1.0.21", "@opentelemetry/api": "1.9.1", "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/exporter-logs-otlp-http": "0.219.0", "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", "@opentelemetry/exporter-trace-otlp-http": "0.219.0", "@opentelemetry/host-metrics": "^0.38.3", "@opentelemetry/instrumentation": "0.219.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/sdk-trace-node": "2.8.0", "@s2-dev/streamstore": "0.25.0", "dequal": "^2.0.3", "eventsource": "^3.0.5", "eventsource-parser": "^3.0.0", "humanize-duration": "^3.27.3", "jose": "^5.4.0", "nanoid": "3.3.18", "prom-client": "^15.1.0", "socket.io-client": "4.8.4", "std-env": "^3.8.1", "tinyexec": "^0.3.2", "uncrypto": "^0.1.3", "zod": "^3.25.56 || ^4.0.0", "zod-validation-error": "^5.0.0" } }, "sha512-V/7nOgnshOMMIUQ392wKAojLmE1t/xuod7WOnFE5s62d2IhnOaYGLBFtN0WnuNQP/GVxmRVGARwRv8j43XH0XA=="], - "@trigger.dev/sdk": ["@trigger.dev/sdk@4.8.2", "", { "dependencies": { "@opentelemetry/api": "1.9.1", "@opentelemetry/semantic-conventions": "1.41.1", "@trigger.dev/core": "4.8.2", "uncrypto": "^0.1.3" }, "peerDependencies": { "@ai-sdk/otel": ">=1.0.0-0 <2", "ai": "^5.0.0 || ^6.0.0 || >=7.0.0-canary <8", "react": "^18.0 || ^19.0", "zod": "^3.25.56 || ^4.0.0" }, "optionalPeers": ["@ai-sdk/otel", "ai", "react"] }, "sha512-IZD8+JfFQK4R7RdgYAeGiBohNfYJbB2I5ScdeUeR0ahLZl9yxXFRG5txByXAYk7FsEJcrvAUp1fq50OHTK0ylw=="], + "@trigger.dev/sdk": ["@trigger.dev/sdk@4.8.3", "", { "dependencies": { "@opentelemetry/api": "1.9.1", "@opentelemetry/semantic-conventions": "1.41.1", "@trigger.dev/core": "4.8.3", "uncrypto": "^0.1.3" }, "peerDependencies": { "@ai-sdk/otel": ">=1.0.0-0 <2", "@aws-sdk/client-sts": "^3.840.0", "ai": "^5.0.0 || ^6.0.0 || >=7.0.0-canary <8", "react": "^18.0 || ^19.0", "zod": "^3.25.56 || ^4.0.0" }, "optionalPeers": ["@ai-sdk/otel", "@aws-sdk/client-sts", "ai", "react"] }, "sha512-0IkDT/mjRXVhRaaXEMlCbqb0YwafgUW75LvkVQMLe3w1L0sO/+Ud/YjNjQqrl8foZO1w2a+a08NEueyNKu9reg=="], "@turbo/darwin-64": ["@turbo/darwin-64@2.9.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-t7QiPflaEyBE4oayeZtSmu4mEfjgIrcNlNNl1z1dmIVPqEdtA7+CfTf8d7KXsOGPh6aNgWjKxyvQg9uGfDQF+A=="], diff --git a/bunfig.toml b/bunfig.toml index c711d24aede..5d546b39d16 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -4,8 +4,10 @@ exact = true # (blocks freshly published, potentially compromised releases). minimumReleaseAge = 604800 # Explicit framework pins must install before their release reaches the age threshold: a -# security release of Next.js is pinned exactly the day it ships. -minimumReleaseAgeExcludes = ["next", "@next/env"] +# security release of Next.js is pinned exactly the day it ships. The Trigger.dev packages are +# excluded only until 4.8.3 (published 2026-10-30, the first release with workload identity) +# clears the threshold on 2026-11-06; drop them then. +minimumReleaseAgeExcludes = ["next", "@next/env", "@trigger.dev/sdk", "@trigger.dev/core", "@trigger.dev/build", "trigger.dev"] [run] env = { NEXT_PUBLIC_APP_URL = "http://localhost:3000" } From e93a105d5b259051729aa259563f9f3947aa8d38 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 2 Nov 2026 12:38:04 -0800 Subject: [PATCH 3/6] refactor(trigger): run the per-attempt setup through one module The init hook loads app modules by relative path, which the source-convention ratchet caps. Warming Redis and refreshing workload-identity credentials now sit behind prepareTriggerAttempt, so init still imports one module. --- apps/sim/lib/core/config/trigger-attempt.ts | 13 +++++++++++++ apps/sim/trigger.config.ts | 13 ++++--------- 2 files changed, 17 insertions(+), 9 deletions(-) create mode 100644 apps/sim/lib/core/config/trigger-attempt.ts diff --git a/apps/sim/lib/core/config/trigger-attempt.ts b/apps/sim/lib/core/config/trigger-attempt.ts new file mode 100644 index 00000000000..c932f59614f --- /dev/null +++ b/apps/sim/lib/core/config/trigger-attempt.ts @@ -0,0 +1,13 @@ +import { refreshAwsCredentialsForRun } from '@/lib/core/config/aws' +import { warmRedisConnection } from '@/lib/core/config/redis' + +/** + * The per-attempt setup the Trigger.dev `init` hook awaits before `run()`. Warms the shared Redis + * connection, and refreshes workload-identity AWS credentials while the attempt is executing and a + * token can still be minted, so work a run leaves behind (the hosted-key metrics flush) has + * credentials after the attempt ends. Never throws, like each step: a throw in `init` fails the + * attempt. + */ +export async function prepareTriggerAttempt(): Promise { + await Promise.all([warmRedisConnection(), refreshAwsCredentialsForRun()]) +} diff --git a/apps/sim/trigger.config.ts b/apps/sim/trigger.config.ts index eebf9fa1217..85e7ba6edf2 100644 --- a/apps/sim/trigger.config.ts +++ b/apps/sim/trigger.config.ts @@ -160,12 +160,8 @@ export default defineConfig({ * because a throw here fails the run. The execution-signal subscriber is * deliberately not warmed here: only the tasks that execute a workflow ever * subscribe, and they are a minority of runs, so that connection is warmed - * on intent at the execution entry point instead. - * - * Also refreshes workload-identity AWS credentials, alongside the Redis - * warm-up, while the attempt is executing and a token can still be minted, - * so work a run leaves behind (the hosted-key metrics flush) has credentials - * after the attempt ends. Never throws, like the warm-up. + * on intent at the execution entry point instead. The same step refreshes + * workload-identity AWS credentials (`prepareTriggerAttempt`). * * @see https://trigger.dev/docs/config/config-file#lifecycle-functions */ @@ -177,9 +173,8 @@ export default defineConfig({ markInsideTriggerRun() const { reportAccountTokenSealing } = await import('@sim/db/sealed-text') reportAccountTokenSealing() - const { warmRedisConnection } = await import('./lib/core/config/redis') - const { refreshAwsCredentialsForRun } = await import('./lib/core/config/aws') - await Promise.all([warmRedisConnection(), refreshAwsCredentialsForRun()]) + const { prepareTriggerAttempt } = await import('./lib/core/config/trigger-attempt') + await prepareTriggerAttempt() }, ...(grafanaTelemetry ? { telemetry: grafanaTelemetry } : {}), build: { From 719cb748befd28a1493194c542abbbe6a15dc4bd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 2 Nov 2026 12:42:23 -0800 Subject: [PATCH 4/6] fix(aws): keep the default chain outside workload identity and cover ses getAwsCredentials returned explicit static keys when present, which dropped AWS_SESSION_TOKEN for clients that used to reach it through the default chain (Bedrock, CloudWatch). It now returns the workload-identity provider or undefined, so every non-Trigger path resolves exactly as before. The SES email client reads it too, so worker email keeps working once static keys leave Trigger.dev. The release-age comment records why the exclusions are bare names. --- apps/sim/lib/core/config/aws.ts | 34 ++++++------------- apps/sim/lib/messaging/email/providers/ses.ts | 8 +++-- bunfig.toml | 16 ++++++--- 3 files changed, 28 insertions(+), 30 deletions(-) diff --git a/apps/sim/lib/core/config/aws.ts b/apps/sim/lib/core/config/aws.ts index c8dedcd3794..266c08a12f8 100644 --- a/apps/sim/lib/core/config/aws.ts +++ b/apps/sim/lib/core/config/aws.ts @@ -4,11 +4,6 @@ import { env } from '@/lib/core/config/env' const logger = createLogger('AwsCredentials') -interface StaticAwsCredentials { - accessKeyId: string - secretAccessKey: string -} - type AwsCredentialsProvider = () => Promise /** @@ -76,27 +71,20 @@ function resolveWorkloadCredentials( } /** - * Credentials for Sim's own AWS clients: file storage (S3), AppConfig, hosted-key metrics and - * hosted Bedrock. Never for a user's integration, which brings its own keys. + * Credentials for Sim's own AWS clients: file storage (S3), AppConfig, email (SES), hosted-key + * metrics and hosted Bedrock. Never for a user's integration, which brings its own keys. * - * - `TRIGGER_AWS_ROLE_ARN` set (deployed Trigger.dev runs): temporary credentials for that role, - * exchanged for the run's Trigger.dev workload-identity token. Takes precedence over static - * keys, so a failed exchange surfaces instead of silently falling back to a stored key. - * - `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` set: those keys (self-hosted deployments). - * - Neither: `undefined`, deferring to the AWS default provider chain (the ECS task role). + * With `TRIGGER_AWS_ROLE_ARN` set (deployed Trigger.dev runs), temporary credentials for that + * role, exchanged for the run's Trigger.dev workload-identity token. It takes precedence over any + * static keys in the environment, so a failed exchange surfaces instead of silently falling back + * to a stored key. Otherwise `undefined`: the AWS SDK's default chain, which reads + * `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and `AWS_SESSION_TOKEN`, then shared profiles, then + * the ECS task role. */ -export function getAwsCredentials(): StaticAwsCredentials | AwsCredentialsProvider | undefined { +export function getAwsCredentials(): AwsCredentialsProvider | undefined { const roleArn = env.TRIGGER_AWS_ROLE_ARN - if (roleArn) { - return () => resolveWorkloadCredentials(roleArn, REFRESH_BEFORE_EXPIRY_MS) - } - if (env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY) { - return { - accessKeyId: env.AWS_ACCESS_KEY_ID, - secretAccessKey: env.AWS_SECRET_ACCESS_KEY, - } - } - return undefined + if (!roleArn) return undefined + return () => resolveWorkloadCredentials(roleArn, REFRESH_BEFORE_EXPIRY_MS) } /** Whether Sim's own AWS clients have credentials of their own rather than the default chain. */ diff --git a/apps/sim/lib/messaging/email/providers/ses.ts b/apps/sim/lib/messaging/email/providers/ses.ts index 2a6fbd701ee..93efc679c06 100644 --- a/apps/sim/lib/messaging/email/providers/ses.ts +++ b/apps/sim/lib/messaging/email/providers/ses.ts @@ -1,20 +1,22 @@ import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2' import nodemailer from 'nodemailer' import type SESTransport from 'nodemailer/lib/ses-transport' +import { getAwsCredentials } from '@/lib/core/config/aws' import { env } from '@/lib/core/config/env' import { sendViaNodemailer } from '@/lib/messaging/email/providers/send-via-nodemailer' import type { MailProvider } from '@/lib/messaging/email/types' /** * AWS SES via nodemailer's SES transport using the AWS SDK v3 client. - * Credentials resolve through the SDK's default provider chain (env vars, - * shared config, ECS/EKS task role, EC2 instance profile, SSO). + * Credentials come from `getAwsCredentials()`: workload identity in Trigger.dev + * runs, otherwise the SDK's default provider chain (env vars, shared config, + * ECS/EKS task role, EC2 instance profile, SSO). */ export function createSesProvider(): MailProvider | null { const region = env.AWS_SES_REGION if (!region) return null - const sesClient = new SESv2Client({ region }) + const sesClient = new SESv2Client({ region, credentials: getAwsCredentials() }) const sesOptions: SESTransport.Options = { SES: { sesClient, SendEmailCommand }, } diff --git a/bunfig.toml b/bunfig.toml index 5d546b39d16..3a21b48225b 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -4,10 +4,18 @@ exact = true # (blocks freshly published, potentially compromised releases). minimumReleaseAge = 604800 # Explicit framework pins must install before their release reaches the age threshold: a -# security release of Next.js is pinned exactly the day it ships. The Trigger.dev packages are -# excluded only until 4.8.3 (published 2026-10-30, the first release with workload identity) -# clears the threshold on 2026-11-06; drop them then. -minimumReleaseAgeExcludes = ["next", "@next/env", "@trigger.dev/sdk", "@trigger.dev/core", "@trigger.dev/build", "trigger.dev"] +# security release of Next.js is pinned exactly the day it ships. Trigger.dev 4.8.3 (published +# 2026-10-30, the first release with workload identity) is exempted until it clears the threshold +# on 2026-11-06; drop the four Trigger.dev entries then. They are bare names because Bun 1.4 ignores +# version-pinned exclusions; `exact = true` and the lockfile still hold the packages at 4.8.3. +minimumReleaseAgeExcludes = [ + "next", + "@next/env", + "@trigger.dev/sdk", + "@trigger.dev/core", + "@trigger.dev/build", + "trigger.dev", +] [run] env = { NEXT_PUBLIC_APP_URL = "http://localhost:3000" } From e47cd97193549e43b2a1a7d65cb3095139d9dee0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 2 Nov 2026 13:01:06 -0800 Subject: [PATCH 5/6] test(uploads): give the presigner credentials where the client now reads them The S3 client resolves credentials through the SDK default chain, which reads process.env, so the presigner test stubs the keys there instead of the env module mock. --- .../uploads/providers/s3/presigned-upload.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/uploads/providers/s3/presigned-upload.test.ts b/apps/sim/lib/uploads/providers/s3/presigned-upload.test.ts index 20eaf1053c8..911a94a7ec5 100644 --- a/apps/sim/lib/uploads/providers/s3/presigned-upload.test.ts +++ b/apps/sim/lib/uploads/providers/s3/presigned-upload.test.ts @@ -4,17 +4,13 @@ * missing from `headers`, or a metadata key both signed as a header and hoisted * into the query, makes the provider reject or strip the upload. */ -import { resetEnvMock, setEnv } from '@sim/testing/mocks/env.mock' import { setUploadsConfig, uploadsConfigMock } from '@sim/testing/mocks/uploads-config.mock' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/uploads/config', () => uploadsConfigMock) import { getS3PresignedUploadUrl, resetS3ClientForTesting } from '@/lib/uploads/providers/s3/client' -setEnv({ AWS_ACCESS_KEY_ID: 'test-access-key', AWS_SECRET_ACCESS_KEY: 'test-secret-key' }) -afterAll(resetEnvMock) - /** Headers the browser sets itself and the uploader never supplies. */ const TRANSPORT_HEADERS = new Set(['host', 'content-length']) @@ -44,7 +40,12 @@ async function presign() { } describe('getS3PresignedUploadUrl', () => { - beforeEach(() => configure(undefined)) + beforeEach(() => { + // The client resolves credentials through the SDK's default chain, which reads process.env. + vi.stubEnv('AWS_ACCESS_KEY_ID', 'test-access-key') + vi.stubEnv('AWS_SECRET_ACCESS_KEY', 'test-secret-key') + configure(undefined) + }) it('signs metadata as uploader-sent headers for a custom S3-compatible endpoint', async () => { configure('https://s3.de.io.cloud.ovh.net') From 1e97ce1e35b3ffa47cf5a0dfe636abc20b8ca185 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 2 Nov 2026 13:22:56 -0800 Subject: [PATCH 6/6] fix(aws): serve unexpired workload credentials when an exchange fails Outside an executing attempt no workload-identity token can be minted, so a post-run metrics flush near the refresh window failed even though the cached credentials were still valid. A failed exchange now keeps serving the cached credentials until a minute before they expire. --- apps/sim/lib/core/config/aws.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/core/config/aws.ts b/apps/sim/lib/core/config/aws.ts index 266c08a12f8..c060e0a048c 100644 --- a/apps/sim/lib/core/config/aws.ts +++ b/apps/sim/lib/core/config/aws.ts @@ -45,16 +45,25 @@ function getWorkloadProvider(roleArn: string): AwsCredentialsProvider { return workloadProvider } +/** Credentials this close to expiry are treated as expired, so a signed request never outlives them. */ +const EXPIRY_SKEW_MS = 60_000 + +function remainingMs(credentials: AWSCredentials | null): number { + return credentials ? credentials.expiration.getTime() - Date.now() : 0 +} + /** * The cached workload credentials while they have more than `minRemainingMs` left, otherwise a - * fresh exchange. Concurrent callers share one exchange. + * fresh exchange. Concurrent callers share one exchange. When the exchange fails, still-valid + * cached credentials are served instead: outside an executing attempt no token can be minted, and + * work a run leaves behind (the metrics flush) must not lose credentials that have not expired. */ function resolveWorkloadCredentials( roleArn: string, minRemainingMs: number ): Promise { const current = currentWorkloadCredentials - if (current && current.expiration.getTime() - Date.now() > minRemainingMs) { + if (current && remainingMs(current) > minRemainingMs) { return Promise.resolve(current) } if (!pendingWorkloadCredentials) { @@ -63,6 +72,15 @@ function resolveWorkloadCredentials( currentWorkloadCredentials = credentials return credentials }) + .catch((error: unknown) => { + const cached = currentWorkloadCredentials + if (!cached || remainingMs(cached) <= EXPIRY_SKEW_MS) throw error + logger.warn('Workload-identity exchange failed; serving cached credentials until expiry', { + error, + remainingMs: remainingMs(cached), + }) + return cached + }) .finally(() => { pendingWorkloadCredentials = null })