From e0bfbfe36837bf707b18006d51af4a7c6742214a Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Wed, 9 Sep 2026 16:20:10 -0700 Subject: [PATCH] fix(trigger): sync worker configuration from Secrets Manager at deploy --- .claude/rules/sim-architecture.md | 91 +++- apps/sim/scripts/trigger-env-sync.test.ts | 314 +++++++++++++ apps/sim/scripts/trigger-env-sync.ts | 545 ++++++++++++++++++++++ apps/sim/trigger.config.ts | 50 +- 4 files changed, 948 insertions(+), 52 deletions(-) create mode 100644 apps/sim/scripts/trigger-env-sync.test.ts create mode 100644 apps/sim/scripts/trigger-env-sync.ts diff --git a/.claude/rules/sim-architecture.md b/.claude/rules/sim-architecture.md index d950851a3f1..9b75d9db4b8 100644 --- a/.claude/rules/sim-architecture.md +++ b/.claude/rules/sim-architecture.md @@ -60,10 +60,93 @@ Every export of a `'use client'` module becomes a *client reference* on the serv ## The app/worker runtime boundary Server code runs in two runtimes with **different environments**. The app container loads the -full env from `SIM_ENV_SECRET_ID` (Secrets Manager). Trigger.dev workers — which execute -workflows, so every block handler and every tool call — get their env from the Trigger.dev -dashboard; `trigger.config.ts` additionally syncs `DB_APP_NAME`, `TRIGGER_DEV_ENABLED`, and the -`FUNCTION_EXECUTION_ENV` vars. The repo cannot see what the dashboard holds. +full env from `SIM_ENV_SECRET_ID` (Secrets Manager). Trigger.dev workers execute application +code directly and receive runtime configuration through Trigger.dev. At deployment, +`trigger.config.ts` calls `scripts/trigger-env-sync.ts` through the existing `syncEnvVars` +extension. It reads only the mapped combined secret's `AWSCURRENT` version, selects approved +platform variables in memory, validates them, and returns explicitly classified secret/public +entries. `DB_APP_NAME=sim-trigger` is fixed; the run `init` marker remains the source of runtime +detection. Reserved `TRIGGER_*` variables, deployment credentials, arbitrary source keys, and +customer credentials are never selected. Customer OAuth tokens and workspace/provider +credentials remain in the application database, decrypted by the existing runtime code. + +```text +ECS boot: environment secret -> runtime-secrets loader -> app process +Trigger deploy: environment secret -> worker policy/validation -> syncEnvVars -> Trigger runtime + + preserved Trigger-owned settings +``` + +### Worker synchronization ownership and rollout + +The mapping is deliberately closed: `preview` with branch `dev-sim` reads `/dev/sim/env-vars`, +`staging` without a branch reads `/staging/sim/env-vars`, and `prod` without a branch reads +`/production/sim/env-vars`. Unknown targets fail before AWS access; no preview-parent writes. +The deployment entrypoint must supply `SIM_TRIGGER_ENV_SYNC_PROJECT_REF` (the approved project, +checked against the callback project) and `SIM_TRIGGER_ENV_SYNC_REGION` (the source region). +These deployment-only controls are not exported to workers. No `NODE_ENV` inference or ambient +source-value fallback is permitted. An unconfigured deployment fails closed; coordinate this +change with the separate deployment-orchestration work before merging/enabling it. + +`WORKER_CONFIGURATION` is the reviewable names/classification/consumer policy. Shared capability +fields, OAuth application registrations, and platform LLM pools come from the existing registry. +Additional groups document their worker consumer and requiredness. Required source settings +include app/auth URLs, encryption/internal authentication, and explicit billing/enterprise flags +(`false` is valid). Capability validators reject incomplete active providers. The source subset +and effective worker configuration are checked so missing values cannot hide behind old values +or silently switch storage/OCR backends. Optional absence is allowed; configured features must +still be usable. Before enabling each target, its owner must approve a names-only source/target +inventory, a supported capability baseline, and the ownership exceptions. Validation does not +prove that a configured endpoint is reachable or a credential is authorized. + +Each target currently preserves the conservative `WORKER_OWNED` list: database URLs (including +role/replica/sub-pool URLs), `SIM_DB_ROLE`, Redis URL/TLS server name, PII endpoint, and Grafana +telemetry settings. The effective database must already be usable. These exceptions apply even +when a source value exists; transfer ownership only through an explicitly reviewed policy change. +The list is a preservation policy, not a claim about the contents of a live target. Staging/prod +project references and deployment entrypoints still require live verification. Telemetry setup, +DB clients, ECS hydration and worker initialization are unchanged. + +Omitted optional keys preserve existing Trigger values, with a names-only notice when the key +was present in the callback's current environment. This is not deletion and does not transfer +ownership. Removed/renamed variables require owner-reviewed retirement in Trigger, including +preview inheritance checks so deleting an override cannot resurrect a parent value. Existing +public variables needing secret classification must be reviewed: Trigger's secret classification +is a creation-time property, so returning `isSecret` must not be treated as an in-place migration. + +Reuse the deployment identity's existing Trigger authentication and AWS default credential chain. +Grant it `secretsmanager:GetSecretValue` on the exact environment secret ARN, and `kms:Decrypt` +only for its customer-managed key when required. No worker Secrets Manager permission is needed. +Runtime AWS credentials selected from the source are platform configuration; runner credentials +are never copied from `process.env`. IAM definition location and staging/prod deploy wiring are +external prerequisites owned by the separate investigation. This step belongs inside every +existing Trigger deployment, after authentication and before the release is accepted. Do not +use `--skip-sync-env-vars` or tolerate nonzero exits. Trigger 4.5.12 catches callback exceptions, +so the adapter logs only controlled categories/names and exits the deploy process with code 1. +CLI environment-import failures must also fail deployment. Never log source objects or SDK/parser +errors, hydrate the deployer, write dotenv/manifests, or pass secrets as image/build arguments. + +Roll out preview/dev-sim, then staging, then production. Synchronization is deployment-time; +rotation without deployment is outside this mechanism. Env import and code promotion are not +atomic: even a later failed build can leave updated configuration. Running/checkpointed jobs +and cached application clients are not guaranteed to adopt updates. Allow old credentials to +remain valid until executions finish, or use a separately authorized drain procedure. Optional +omission and code rollback do not restore prior values. + +One disposable non-production smoke test is sufficient after access and ownership approval: +use a disposable Trigger project and an isolated non-production AWS account with fake platform +configuration at `/dev/sim/env-vars`. Supply that project and region through the deployment +controls, preseed fake Trigger-owned configuration, and invoke the existing deployment path for +`preview/dev-sim`. Verify creation, +update, unrelated-variable preservation and optional omission. Assert a fresh job sees expected +values without printing them. Check build artifacts and logs for the fake marker, explicitly +remove test variables (including inherited preview values), and delete the disposable secret +and project. Do not point the smoke test at an environment's real combined secret. Do not add +this live test to CI or use production credentials. Automatic deletion/rotation behavior is not +implemented; any explicit deletion test must account for preview inheritance. + +References: [Trigger syncEnvVars](https://trigger.dev/docs/config/extensions/syncEnvVars), +[Trigger environment variables](https://trigger.dev/docs/deploy-environment-variables), +[AWS GetSecretValue](https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html). So before replacing a worker's HTTP call to our own API with an in-process call, ask what env that work reads *on the app side*. Anything gated by a `require*Capability` helper is the sharp diff --git a/apps/sim/scripts/trigger-env-sync.test.ts b/apps/sim/scripts/trigger-env-sync.test.ts new file mode 100644 index 00000000000..6f6af1f2da0 --- /dev/null +++ b/apps/sim/scripts/trigger-env-sync.test.ts @@ -0,0 +1,314 @@ +/** @vitest-environment node */ +import { createLogger } from '@sim/logger' +import { syncEnvVars } from '@trigger.dev/build/extensions/core' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + readWorkerConfiguration, + selectWorkerConfiguration, + syncWorkerEnvironment, +} from '@/scripts/trigger-env-sync' + +const aws = vi.hoisted(() => ({ + send: vi.fn(), + client: vi.fn(), + command: vi.fn(), + destroy: vi.fn(), +})) +vi.mock('@aws-sdk/client-secrets-manager', () => ({ + SecretsManagerClient: class { + constructor(options: unknown) { + aws.client(options) + } + send = aws.send + destroy = aws.destroy + }, + GetSecretValueCommand: class { + constructor(input: unknown) { + aws.command(input) + } + }, +})) + +const deployment = { expectedProjectRef: 'proj_test', region: 'us-east-1' } +const context = { + projectRef: 'proj_test', + environment: 'preview', + branch: 'dev-sim', + env: { DATABASE_URL: 'postgresql://worker.invalid/sim', SIM_DB_ROLE: 'web' }, +} +const core = { + BETTER_AUTH_SECRET: 'test-auth-key-'.repeat(3), + ENCRYPTION_KEY: 'a'.repeat(64), + INTERNAL_API_SECRET: 'test-internal-key-'.repeat(3), + BETTER_AUTH_URL: 'https://dev.sim.ai', + NEXT_PUBLIC_APP_URL: 'https://dev.sim.ai', + BILLING_ENABLED: false, + ENTERPRISE_ENABLED: false, +} +const sentinel = 'FAKE_SECRET_MUST_NOT_APPEAR_IN_ERRORS' +const syncLogger = + vi.mocked(createLogger).mock.results[ + vi.mocked(createLogger).mock.calls.findIndex(([name]) => name === 'TriggerEnvSync') + ].value + +beforeEach(() => { + vi.clearAllMocks() + aws.send.mockReset().mockResolvedValue({ SecretString: JSON.stringify(core) }) +}) +afterEach(() => vi.restoreAllMocks()) + +describe('target mapping', () => { + it.each([ + ['preview', 'dev-sim', '/dev/sim/env-vars'], + ['staging', undefined, '/staging/sim/env-vars'], + ['prod', undefined, '/production/sim/env-vars'], + ])('maps %s without inheriting a preview parent', async (environment, branch, SecretId) => { + await readWorkerConfiguration({ ...context, environment: environment!, branch }, deployment) + expect(aws.command).toHaveBeenCalledWith({ SecretId, VersionStage: 'AWSCURRENT' }) + }) + + it.each([ + { projectRef: 'wrong-project' }, + { environment: 'production', branch: undefined }, + { environment: 'dev', branch: undefined }, + { branch: 'unknown' }, + { branch: undefined }, + { environment: 'staging', branch: 'dev-sim' }, + { environment: 'prod', branch: 'dev-sim' }, + ])('rejects mismatched target before AWS access: %j', async (overrides) => { + await expect( + readWorkerConfiguration({ ...context, ...overrides }, deployment) + ).rejects.toThrow() + expect(aws.client).not.toHaveBeenCalled() + }) + + it.each([{ region: undefined }, { region: 'invalid' }, { expectedProjectRef: undefined }])( + 'requires explicit deployment configuration: %j', + async (overrides) => { + await expect( + readWorkerConfiguration(context, { ...deployment, ...overrides }) + ).rejects.toThrow() + expect(aws.client).not.toHaveBeenCalled() + } + ) +}) + +describe('selection and ownership', () => { + it('selects only approved platform names and preserves worker-owned and unrelated configuration', () => { + const current = { + ...context.env, + DATABASE_URL_TRIGGER: 'postgresql://private.invalid/sim', + REDIS_URL: 'rediss://cache.invalid:6379', + REDIS_TLS_SERVERNAME: 'cache.invalid', + PII_URL: 'https://pii.worker.invalid', + GRAFANA_OTLP_HEADERS: 'worker-only', + OPENAI_API_KEY: 'existing-optional', + UNRELATED_SETTING: 'unrelated', + } + const before = { ...current } + const result = selectWorkerConfiguration( + { + ...core, + DATABASE_URL: sentinel, + REDIS_URL: sentinel, + PII_URL: sentinel, + SIM_DB_ROLE: 'trigger', + GRAFANA_OTLP_HEADERS: sentinel, + TRIGGER_SECRET_KEY: sentinel, + TRIGGER_DEV_ENABLED: true, + TRIGGER_ACCESS_TOKEN: sentinel, + GITHUB_TOKEN: sentinel, + CUSTOMER_API_KEY: sentinel, + WORKSPACE_ENVIRONMENT: sentinel, + SIM_ENV_SECRET_ID: sentinel, + AWS_SESSION_TOKEN: sentinel, + UNRELATED_SETTING: sentinel, + GOOGLE_CLIENT_ID: 'client-id', + GOOGLE_CLIENT_SECRET: 'client-secret', + RESEND_API_KEY: ' exact-value ', + }, + current, + 'preview/dev-sim' + ) + expect(result.variables).toEqual( + expect.arrayContaining([ + { name: 'DB_APP_NAME', value: 'sim-trigger', isSecret: false }, + { name: 'GOOGLE_CLIENT_ID', value: 'client-id', isSecret: false }, + { name: 'GOOGLE_CLIENT_SECRET', value: 'client-secret', isSecret: true }, + { name: 'RESEND_API_KEY', value: ' exact-value ', isSecret: true }, + ]) + ) + expect(JSON.stringify(result.variables)).not.toContain(sentinel) + expect(result.variables.some(({ name }) => Object.hasOwn(current, name))).toBe(false) + expect(result.omitted).toEqual(['OPENAI_API_KEY']) + expect(current).toEqual(before) + }) +}) + +describe('requiredness', () => { + it.each([ + 'BETTER_AUTH_SECRET', + 'ENCRYPTION_KEY', + 'INTERNAL_API_SECRET', + 'BETTER_AUTH_URL', + 'NEXT_PUBLIC_APP_URL', + 'BILLING_ENABLED', + 'ENTERPRISE_ENABLED', + ] as const)('rejects absent or blank core %s even when Trigger already has it', (name) => { + for (const value of [undefined, ' ']) { + expect(() => + selectWorkerConfiguration( + { ...core, [name]: value }, + { ...context.env, [name]: String(core[name]) }, + 'staging' + ) + ).toThrow(name) + } + }) + + it('requires a usable effective database without changing the existing pool role', () => { + expect(() => selectWorkerConfiguration(core, {}, 'prod')).toThrow('database') + expect(() => + selectWorkerConfiguration( + core, + { + DATABASE_URL: 'postgresql://base.invalid/db', + SIM_DB_ROLE: 'trigger', + DATABASE_URL_TRIGGER: '', + }, + 'prod' + ) + ).toThrow('database') + expect(() => + selectWorkerConfiguration( + core, + { SIM_DB_ROLE: 'trigger', DATABASE_URL_TRIGGER: 'postgresql://private.invalid/db' }, + 'prod' + ) + ).not.toThrow() + }) + + it('rejects incomplete active capabilities even if existing values could mask the missing source', () => { + expect(() => + selectWorkerConfiguration( + { ...core, SMTP_HOST: 'smtp.invalid' }, + { ...context.env, SMTP_PORT: '587' }, + 'staging' + ) + ).toThrow('capability') + }) + + it('rejects preserved settings that would silently select a different backend', () => { + expect(() => + selectWorkerConfiguration( + { ...core, S3_BUCKET_NAME: 'files', AWS_REGION: 'us-east-1' }, + { ...context.env, STORAGE_PROVIDER: 'local' }, + 'staging' + ) + ).toThrow('preserved-provider-conflict') + }) + + it('allows absent optional capabilities, preserves false/zero, and serializes JSON credentials', () => { + const result = selectWorkerConfiguration( + { + ...core, + FREE_TIER_COST_LIMIT: 0, + GMAIL_CREDENTIALS_JSON: { client_email: 'test@example.invalid', private_key: 'fake' }, + GMAIL_SENDER: 'test@example.invalid', + }, + context.env, + 'staging' + ) + expect(result.variables).toEqual( + expect.arrayContaining([ + { name: 'BILLING_ENABLED', value: 'false', isSecret: false }, + { name: 'FREE_TIER_COST_LIMIT', value: '0', isSecret: false }, + { + name: 'GMAIL_CREDENTIALS_JSON', + value: JSON.stringify({ client_email: 'test@example.invalid', private_key: 'fake' }), + isSecret: true, + }, + ]) + ) + }) +}) + +describe('source failures', () => { + it.each([ + { SecretString: `${sentinel}{` }, + { SecretString: '[]' }, + { SecretString: 'null' }, + { SecretString: JSON.stringify(sentinel) }, + { SecretBinary: new Uint8Array([1]) }, + ])('sanitizes unsupported responses', async (response) => { + aws.send.mockResolvedValue(response) + await expect(readWorkerConfiguration(context, deployment)).rejects.toThrow( + /^Worker configuration sync failed: secret-/ + ) + expect(aws.destroy).toHaveBeenCalledOnce() + }) + + it('does not expose AWS rejection text or malformed selector values', async () => { + aws.send.mockRejectedValue(new Error(sentinel)) + await expect(readWorkerConfiguration(context, deployment)).rejects.toThrow( + 'Worker configuration sync failed: aws-fetch' + ) + expect(() => + selectWorkerConfiguration({ ...core, STORAGE_PROVIDER: sentinel }, context.env, 'prod') + ).toThrow(/^Worker configuration sync failed: capability \([A-Z0-9_, ]+\)$/) + }) +}) + +describe('AWS request boundary', () => { + it('uses explicit region and AWSCURRENT with bounded retries and leaves process.env alone', async () => { + vi.stubEnv('AWS_REGION', 'eu-west-1') + vi.stubEnv('RESEND_API_KEY', sentinel) + const before = { ...process.env } + const result = await readWorkerConfiguration(context, deployment) + expect(aws.client).toHaveBeenCalledWith({ region: 'us-east-1', maxAttempts: 3 }) + expect(aws.command).toHaveBeenCalledWith({ + SecretId: '/dev/sim/env-vars', + VersionStage: 'AWSCURRENT', + }) + expect(aws.send.mock.calls[0][1].abortSignal).toBeInstanceOf(AbortSignal) + expect(aws.destroy).toHaveBeenCalledOnce() + expect(Object.keys(process.env)).toEqual(Object.keys(before)) + expect(Object.keys(before).every((name) => process.env[name] === before[name])).toBe(true) + expect(result.variables.some(({ name }) => name === 'RESEND_API_KEY')).toBe(false) + }) +}) + +describe('fatal integration boundary', () => { + it('requests exit(1) inside the pinned extension, which swallows thrown callback errors', async () => { + vi.stubEnv('SIM_TRIGGER_ENV_SYNC_PROJECT_REF', deployment.expectedProjectRef) + vi.stubEnv('SIM_TRIGGER_ENV_SYNC_REGION', deployment.region) + aws.send.mockRejectedValue(new Error(sentinel)) + const exit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('intercepted exit') + }) + const extension = syncEnvVars(syncWorkerEnvironment) + const buildContext = { + target: 'deploy', + config: { project: context.projectRef }, + logger: { spinner: () => ({ stop: vi.fn() }), warn: vi.fn() }, + addLayer: vi.fn(), + } + const manifest = { + deploy: { env: context.env }, + environment: context.environment, + branch: context.branch, + } + await extension.onBuildComplete!( + buildContext as Parameters>[0], + manifest as Parameters>[1] + ) + expect(exit).toHaveBeenCalledWith(1) + expect(buildContext.addLayer).not.toHaveBeenCalled() + expect(JSON.stringify(buildContext.logger.warn.mock.calls)).not.toContain(sentinel) + expect(syncLogger.error).toHaveBeenCalledWith( + 'Worker configuration sync failed; deployment aborted', + { category: 'aws-fetch', names: [] } + ) + expect(JSON.stringify(vi.mocked(syncLogger.error).mock.calls)).not.toContain(sentinel) + }) +}) diff --git a/apps/sim/scripts/trigger-env-sync.ts b/apps/sim/scripts/trigger-env-sync.ts new file mode 100644 index 00000000000..f18a3321cd4 --- /dev/null +++ b/apps/sim/scripts/trigger-env-sync.ts @@ -0,0 +1,545 @@ +import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager' +import { + CACHE_CAPABILITY, + type CapabilityDefinition, + EMAIL_CAPABILITY, + getCapabilityFields, + hasEnvCapabilityValue, + inspectCapability, + inspectOAuthClientCapability, + isTruthyEnvCapabilityValue, + KNOWLEDGE_EMBEDDINGS_CAPABILITY, + LLM_KEY_POOLS, + OAUTH_CLIENT_CAPABILITIES, + OCR_CAPABILITY, + SANDBOX_CAPABILITY, + STORAGE_CAPABILITY, +} from '@sim/deployment-config/env-capabilities' +import { createLogger } from '@sim/logger' +import type { syncEnvVars } from '@trigger.dev/build/extensions/core' + +const logger = createLogger('TriggerEnvSync') +type SyncContext = Parameters[0]>[0] + +interface DeploymentConfiguration { + expectedProjectRef?: string + region?: string +} + +interface WorkerVariable { + name: string + isSecret: boolean + consumer: string + requiredness: 'required' | 'capability' | 'optional' +} + +/** Conservative ownership until each target's configuration owner approves a transfer. */ +const WORKER_OWNED = [ + 'DATABASE_URL', + 'DATABASE_URL_WEB', + 'DATABASE_URL_TRIGGER', + 'DATABASE_URL_REALTIME', + 'DATABASE_URL_CLEANUP', + 'DATABASE_URL_EXEC', + 'DATABASE_REPLICA_URL', + 'DATABASE_REPLICA_URL_WEB', + 'DATABASE_REPLICA_URL_TRIGGER', + 'DATABASE_REPLICA_URL_REALTIME', + 'SIM_DB_ROLE', + 'REDIS_URL', + 'REDIS_TLS_SERVERNAME', + 'PII_URL', + 'GRAFANA_OTLP_ENDPOINT', + 'GRAFANA_OTLP_HEADERS', + 'GRAFANA_DEPLOYMENT_ENVIRONMENT', +] as const + +const TARGETS = { + 'preview/dev-sim': { secretId: '/dev/sim/env-vars', workerOwned: WORKER_OWNED }, + staging: { secretId: '/staging/sim/env-vars', workerOwned: WORKER_OWNED }, + prod: { secretId: '/production/sim/env-vars', workerOwned: WORKER_OWNED }, +} as const + +type Target = keyof typeof TARGETS + +/** Errors expose only controlled categories and allowlisted names, never underlying messages. */ +class SyncFailure extends Error { + constructor( + readonly category: string, + readonly names: readonly string[] = [] + ) { + super( + `Worker configuration sync failed: ${category}${names.length ? ` (${names.join(', ')})` : ''}` + ) + } +} + +export function resolveWorkerSyncTarget( + context: SyncContext, + configuration: DeploymentConfiguration +) { + if ( + !configuration.expectedProjectRef || + context.projectRef !== configuration.expectedProjectRef + ) { + throw new SyncFailure('project') + } + if (!configuration.region || !/^[a-z]{2}(?:-[a-z]+)+-\d+$/.test(configuration.region)) { + throw new SyncFailure('region') + } + const target: Target | undefined = + context.environment === 'preview' && context.branch === 'dev-sim' + ? 'preview/dev-sim' + : context.branch === undefined && + (context.environment === 'staging' || context.environment === 'prod') + ? context.environment + : undefined + if (!target) throw new SyncFailure('target') + return { target, region: configuration.region, ...TARGETS[target] } +} + +const CAPABILITIES: readonly CapabilityDefinition[] = [ + STORAGE_CAPABILITY, + SANDBOX_CAPABILITY, + EMAIL_CAPABILITY, + OCR_CAPABILITY, + KNOWLEDGE_EMBEDDINGS_CAPABILITY, + CACHE_CAPABILITY, +] + +/** New shared fields default to secret; only explicitly reviewed configuration is public. */ +const PUBLIC_CAPABILITY_FIELDS = new Set([ + 'STORAGE_PROVIDER', + 'SANDBOX_PROVIDER', + 'OCR_PROVIDER', + 'E2B_ENABLED', + 'NEXT_PUBLIC_E2B_ENABLED', + 'NEXT_PUBLIC_SANDBOXES_ENABLED', + 'E2B_FUNCTION_TEMPLATE_ID', + 'E2B_FUNCTION_TEMPLATE_GENERATION', + 'DAYTONA_FUNCTION_SNAPSHOT_ID', + 'AWS_REGION', + 'AWS_SES_REGION', + 'S3_ENDPOINT', + 'S3_FORCE_PATH_STYLE', + 'S3_BUCKET_NAME', + 'S3_KB_BUCKET_NAME', + 'S3_EXECUTION_FILES_BUCKET_NAME', + 'S3_CHAT_BUCKET_NAME', + 'S3_COPILOT_BUCKET_NAME', + 'S3_PROFILE_PICTURES_BUCKET_NAME', + 'S3_OG_IMAGES_BUCKET_NAME', + 'S3_WORKSPACE_LOGOS_BUCKET_NAME', + 'AZURE_ACCOUNT_NAME', + 'AZURE_STORAGE_CONTAINER_NAME', + 'GCS_BUCKET_NAME', + 'GCS_PROJECT_ID', + 'SMTP_HOST', + 'SMTP_PORT', + 'GMAIL_SENDER', + 'OCR_AZURE_ENDPOINT', + 'OCR_AZURE_MODEL_NAME', + 'AZURE_OPENAI_ENDPOINT', + 'AZURE_OPENAI_API_VERSION', + 'KB_OPENAI_MODEL_NAME', + 'KB_EMBEDDING_MODEL', + 'EMBEDDING_OUTPUT_DIMS', + 'OLLAMA_URL', + 'REDIS_TLS_SERVERNAME', +]) + +function variables( + names: readonly string[], + consumer: string, + isSecret: boolean, + requiredness: WorkerVariable['requiredness'] = 'optional' +): WorkerVariable[] { + return names.map((name) => ({ name, consumer, isSecret, requiredness })) +} + +/** + * Platform configuration only. OAuth entries are application client registrations; + * account tokens, workspace secrets and customer provider keys stay in the database. + * Each additional group records its in-worker consumer and presence policy. + */ +export const WORKER_CONFIGURATION: readonly WorkerVariable[] = [ + ...CAPABILITIES.flatMap((capability) => + getCapabilityFields(capability).map((name) => ({ + name, + consumer: `shared capability: ${capability.id}`, + isSecret: !PUBLIC_CAPABILITY_FIELDS.has(name), + requiredness: 'capability' as const, + })) + ), + ...Object.entries(OAUTH_CLIENT_CAPABILITIES).flatMap(([provider, names]) => + names.map((name) => ({ + name, + consumer: `OAuth refresh: ${provider}`, + isSecret: !name.endsWith('_CLIENT_ID'), + requiredness: 'capability' as const, + })) + ), + ...Object.values(LLM_KEY_POOLS).flatMap((pool) => + variables( + [...pool.keys, ...('fallbackKey' in pool ? [pool.fallbackKey] : [])], + 'providers/utils.ts: platform LLM key pools', + true, + 'capability' + ) + ), + ...variables( + ['BETTER_AUTH_SECRET', 'ENCRYPTION_KEY', 'INTERNAL_API_SECRET'], + 'auth, credential decryption and internal operation authentication', + true, + 'required' + ), + ...variables( + ['BETTER_AUTH_URL', 'NEXT_PUBLIC_APP_URL'], + 'auth callbacks, execution URLs and env-flags hosted detection', + false, + 'required' + ), + ...variables( + ['API_ENCRYPTION_KEY', 'INTERNAL_JWT_SECRET'], + 'lib/core/security: dedicated encryption and internal JWT keys', + true + ), + ...variables( + WORKER_OWNED.filter((name) => name.startsWith('DATABASE_')), + 'packages/db: process, replica and cleanup/exec pools; Trigger-owned', + true + ), + ...variables(['SIM_DB_ROLE'], 'packages/db: existing process pool profile; Trigger-owned', false), + ...variables(['PII_URL'], 'lib/execution: payload redaction endpoint; Trigger-owned', false), + ...variables(['GRAFANA_OTLP_HEADERS'], 'trigger.config.ts: telemetry; Trigger-owned', true), + ...variables( + ['GRAFANA_OTLP_ENDPOINT', 'GRAFANA_DEPLOYMENT_ENVIRONMENT'], + 'trigger.config.ts: telemetry; Trigger-owned', + false + ), + ...variables( + [ + 'MOTHERSHIP_E2B_DOC_TEMPLATE_ID', + 'MOTHERSHIP_E2B_TEMPLATE_ID', + 'E2B_PI_TEMPLATE_ID', + 'E2B_DOMAIN', + 'DAYTONA_DOC_SNAPSHOT_ID', + 'DAYTONA_SHELL_SNAPSHOT_ID', + 'DAYTONA_PI_SNAPSHOT_ID', + ], + 'lib/execution/sandbox: document, shell and agent sandbox selection', + false, + 'capability' + ), + ...variables( + [ + 'AZURE_STORAGE_KB_CONTAINER_NAME', + 'AZURE_STORAGE_EXECUTION_FILES_CONTAINER_NAME', + 'AZURE_STORAGE_CHAT_CONTAINER_NAME', + 'AZURE_STORAGE_COPILOT_CONTAINER_NAME', + 'AZURE_STORAGE_PROFILE_PICTURES_CONTAINER_NAME', + 'AZURE_STORAGE_OG_IMAGES_CONTAINER_NAME', + 'AZURE_STORAGE_WORKSPACE_LOGOS_CONTAINER_NAME', + 'GCS_KB_BUCKET_NAME', + 'GCS_EXECUTION_FILES_BUCKET_NAME', + 'GCS_CHAT_BUCKET_NAME', + 'GCS_COPILOT_BUCKET_NAME', + 'GCS_PROFILE_PICTURES_BUCKET_NAME', + 'GCS_OG_IMAGES_BUCKET_NAME', + 'GCS_WORKSPACE_LOGOS_BUCKET_NAME', + ], + 'lib/uploads/config.ts: worker file-storage contexts', + false, + 'capability' + ), + ...variables( + ['EMAIL_DOMAIN', 'FROM_EMAIL_ADDRESS', 'PERSONAL_EMAIL_FROM', 'SMTP_SECURE', 'SMTP_EHLO_NAME'], + 'lib/messaging/email: background notification sender', + false + ), + ...variables( + ['BILLING_ENABLED', 'ENTERPRISE_ENABLED'], + 'lib/core/config/env-flags: billing and enterprise execution policy', + false, + 'required' + ), + ...variables( + [ + 'INBOX_ENABLED', + 'SANDBOXES_ENABLED', + 'ACCESS_CONTROL_ENABLED', + 'ORGANIZATIONS_ENABLED', + 'USAGE_MONITORING_ENABLED', + 'DATA_RETENTION_ENABLED', + 'DATA_DRAINS_ENABLED', + 'AUDIT_LOGS_ENABLED', + 'COPILOT_TOOL_PERMISSIONS_ENABLED', + 'DURABLE_SECRET_PROVENANCE_ENFORCED_SURFACES', + 'ALLOWED_INTEGRATIONS', + 'BLACKLISTED_PROVIDERS', + 'ALLOWED_MCP_DOMAINS', + 'EGRESS_ALLOWED_HOSTS', + 'EGRESS_ALLOWED_IP_RANGES', + 'ALLOW_PRIVATE_DATABASE_HOSTS', + ], + 'env-flags and execution policy: hosted feature and egress gates', + false + ), + ...variables( + ['STRIPE_SECRET_KEY'], + 'lib/billing: usage and subscription jobs', + true, + 'capability' + ), + ...variables( + [ + 'STRIPE_FREE_PRICE_ID', + 'STRIPE_PRO_PRICE_ID', + 'STRIPE_TEAM_PRICE_ID', + 'STRIPE_ENTERPRISE_PRICE_ID', + 'STRIPE_PRICE_TIER_25_MO', + 'STRIPE_PRICE_TIER_100_MO', + 'STRIPE_PRICE_TIER_25_YR', + 'STRIPE_PRICE_TIER_100_YR', + 'STRIPE_PRICE_TEAM_25_MO', + 'STRIPE_PRICE_TEAM_25_YR', + 'STRIPE_PRICE_TEAM_100_MO', + 'STRIPE_PRICE_TEAM_100_YR', + 'FREE_TIER_COST_LIMIT', + 'PRO_TIER_COST_LIMIT', + 'TEAM_TIER_COST_LIMIT', + 'ENTERPRISE_TIER_COST_LIMIT', + 'FREE_STORAGE_LIMIT_GB', + 'PRO_STORAGE_LIMIT_GB', + 'TEAM_STORAGE_LIMIT_GB', + 'BILLING_CONCURRENCY_LIMIT_PRO', + 'BILLING_CONCURRENCY_LIMIT_TEAM', + 'BILLING_CONCURRENCY_LIMIT_ENTERPRISE', + 'COST_MULTIPLIER', + 'OVERAGE_THRESHOLD_DOLLARS', + ], + 'lib/billing: plan limits, price lookup and usage enforcement', + false + ), + ...variables( + ['APPCONFIG_APPLICATION', 'APPCONFIG_ENVIRONMENT'], + 'lib/core/config: hosted access-control AppConfig client', + false, + 'capability' + ), + ...variables(['AGENTMAIL_API_KEY'], 'lib/mothership: inbox service', true, 'capability'), + ...variables(['AGENTMAIL_DOMAIN'], 'lib/mothership: inbox domain', false), + ...variables( + ['COPILOT_API_KEY'], + 'lib/mothership: agent service authentication', + true, + 'capability' + ), + ...variables( + ['SIM_AGENT_API_URL', 'COPILOT_SOURCE_ENV'], + 'lib/mothership: agent service routing', + false + ), +] + +function serializeValue(value: unknown, name: string): string | undefined { + if (value === null || value === undefined || (typeof value === 'string' && !value.trim())) return + if (typeof value === 'string') return value + if (typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value))) + return JSON.stringify(value) + if (name.endsWith('_JSON') && typeof value === 'object' && !Array.isArray(value)) + return JSON.stringify(value) + throw new SyncFailure('value-type', [name]) +} + +function validateCapabilities(values: Record) { + for (const definition of CAPABILITIES) { + const inspection = inspectCapability(definition, values) + const broken = inspection.providers.filter( + (provider) => provider.state === 'partial' || provider.state === 'invalid' + ) + if (inspection.error || broken.length) { + throw new SyncFailure('capability', getCapabilityFields(definition)) + } + } + for (const provider of Object.keys(OAUTH_CLIENT_CAPABILITIES)) { + const inspection = inspectOAuthClientCapability(provider, values) + if (inspection.state === 'partial') + throw new SyncFailure('oauth-pair', inspection.missingFields) + } + for (const [flag, required] of [ + ['BILLING_ENABLED', 'STRIPE_SECRET_KEY'], + ['INBOX_ENABLED', 'AGENTMAIL_API_KEY'], + ]) { + if (isTruthyEnvCapabilityValue(values, flag) && !hasEnvCapabilityValue(values, required)) { + throw new SyncFailure('enabled-feature', [required]) + } + } + if (values.APPCONFIG_APPLICATION || values.APPCONFIG_ENVIRONMENT) { + for (const name of ['APPCONFIG_APPLICATION', 'APPCONFIG_ENVIRONMENT', 'AWS_REGION']) { + if (!hasEnvCapabilityValue(values, name)) throw new SyncFailure('appconfig', [name]) + } + } +} + +function validateCore(values: Record) { + for (const name of [ + 'BETTER_AUTH_SECRET', + 'INTERNAL_API_SECRET', + 'INTERNAL_JWT_SECRET', + 'API_ENCRYPTION_KEY', + ]) { + if (values[name] !== undefined && values[name].trim().length < 32) + throw new SyncFailure('authentication', [name]) + } + if (!/^[a-fA-F0-9]{64}$/.test(values.ENCRYPTION_KEY)) + throw new SyncFailure('encryption', ['ENCRYPTION_KEY']) + for (const name of ['NEXT_PUBLIC_APP_URL', 'BETTER_AUTH_URL']) { + try { + if (!['https:', 'http:'].includes(new URL(values[name]).protocol)) throw new Error() + } catch { + throw new SyncFailure('url', [name]) + } + } + for (const name of ['BILLING_ENABLED', 'ENTERPRISE_ENABLED']) { + if (!/^(true|false|1|0|yes|no|on|off)$/i.test(values[name].trim())) + throw new SyncFailure('boolean', [name]) + } +} + +/** Select source-owned values without using the deployer's runtime environment as fallback. */ +export function selectWorkerConfiguration( + source: Record, + current: Record, + target: Target +) { + const owned = new Set(TARGETS[target].workerOwned) + const policy = new Map(WORKER_CONFIGURATION.map((entry) => [entry.name, entry])) + const selected: Record = {} + const effective: Record = {} + const omitted: string[] = [] + for (const [name, entry] of policy) { + if (name.startsWith('TRIGGER_')) continue + if (Object.hasOwn(current, name)) effective[name] = current[name] + if (owned.has(name)) { + if (Object.hasOwn(current, name)) selected[name] = current[name] + continue + } + const value = serializeValue(Object.hasOwn(source, name) ? source[name] : undefined, name) + if (value === undefined) { + if (entry.requiredness === 'required') throw new SyncFailure('required', [name]) + if (Object.hasOwn(current, name)) omitted.push(name) + } else { + selected[name] = value + effective[name] = value + } + } + validateCore(selected) + validateCore(effective) + validateCapabilities(selected) + validateCapabilities(effective) + for (const definition of CAPABILITIES) { + if (definition.strategy !== 'selected' || definition === CACHE_CAPABILITY) continue + if (!getCapabilityFields(definition).some((name) => hasEnvCapabilityValue(selected, name))) + continue + const intended = inspectCapability(definition, selected) + const actual = inspectCapability(definition, effective) + if (intended.providerId !== actual.providerId) { + throw new SyncFailure('preserved-provider-conflict', getCapabilityFields(definition)) + } + } + const role = current.SIM_DB_ROLE?.trim() || 'web' + if (!['web', 'trigger', 'realtime'].includes(role)) + throw new SyncFailure('database-role', ['SIM_DB_ROLE']) + const databaseKey = `DATABASE_URL_${role.toUpperCase()}` + const databaseUrl = current[databaseKey] ?? current.DATABASE_URL + try { + if ( + !databaseUrl || + !['postgres:', 'postgresql:'].includes(new URL(databaseUrl).protocol) || + !new URL(databaseUrl).hostname + ) + throw new Error() + } catch { + throw new SyncFailure('database', [databaseKey, 'DATABASE_URL']) + } + return { + variables: [ + { name: 'DB_APP_NAME', value: 'sim-trigger', isSecret: false }, + ...Object.entries(selected).flatMap(([name, value]) => + owned.has(name) + ? [] + : [ + { + name, + value, + isSecret: policy.get(name)!.isSecret, + }, + ] + ), + ], + omitted, + } +} + +/** Bounded default-chain AWS access. The combined object is never hydrated or persisted. */ +export async function readWorkerConfiguration( + context: SyncContext, + configuration: DeploymentConfiguration +) { + const mapping = resolveWorkerSyncTarget(context, configuration) + const client = new SecretsManagerClient({ region: mapping.region, maxAttempts: 3 }) + let source: unknown + try { + let secretString: string | undefined + try { + const response = await client.send( + new GetSecretValueCommand({ + SecretId: mapping.secretId, + VersionStage: 'AWSCURRENT', + }), + { abortSignal: AbortSignal.timeout(15_000) } + ) + secretString = response.SecretString + } catch { + throw new SyncFailure('aws-fetch') + } + if (!secretString) throw new SyncFailure('secret-format') + try { + source = JSON.parse(secretString) + } catch { + throw new SyncFailure('secret-json') + } + } finally { + client.destroy() + } + if (!source || typeof source !== 'object' || Array.isArray(source)) + throw new SyncFailure('secret-object') + return selectWorkerConfiguration(source as Record, context.env, mapping.target) +} + +/** + * Trigger 4.5.12 catches callback exceptions and continues the build. Exit the + * deployment process on failure; throwing or returning [] is not a release gate. + * Configuration comes from deployment wiring, never from the fetched source. + */ +export async function syncWorkerEnvironment(context: SyncContext) { + try { + const result = await readWorkerConfiguration(context, { + expectedProjectRef: process.env.SIM_TRIGGER_ENV_SYNC_PROJECT_REF, + region: process.env.SIM_TRIGGER_ENV_SYNC_REGION, + }) + if (result.omitted.length) + logger.warn('Managed variables absent from source are preserved; review retirement', { + names: result.omitted, + }) + logger.info('Worker configuration selected', { count: result.variables.length }) + return result.variables + } catch (error) { + logger.error('Worker configuration sync failed; deployment aborted', { + category: error instanceof SyncFailure ? error.category : 'unexpected', + names: error instanceof SyncFailure ? error.names : [], + }) + process.exit(1) + } +} diff --git a/apps/sim/trigger.config.ts b/apps/sim/trigger.config.ts index 6a218121ee5..826fd53872b 100644 --- a/apps/sim/trigger.config.ts +++ b/apps/sim/trigger.config.ts @@ -8,6 +8,7 @@ import { syncEnvVars, } from '@trigger.dev/build/extensions/core' import { defineConfig } from '@trigger.dev/sdk' +import { syncWorkerEnvironment } from '@/scripts/trigger-env-sync' import { env } from './lib/core/config/env' import { markInsideTriggerRun } from './lib/core/config/trigger-runtime' import { parseOtlpHeaders } from './lib/monitoring/otlp' @@ -26,43 +27,6 @@ if (grafanaConfigured && !grafanaFullyConfigured) { ) } -/** - * Environment a run needs for sandboxed work. Function block runs and the - * document compiler share one provider selection, and the doc-template - * variables decide whether a run reads a generated document through the doc - * sandbox's artifact store or the isolated-vm fallback. The app authors - * documents for whichever compiler it sees, so a worker missing the doc - * template falls back to isolated-vm and tries to run Python or Node-style - * sources as sandbox JavaScript. Reading a generated document under the doc - * sandbox means loading its compiled artifact from the copilot storage - * context, so that bucket has to be visible to the run as well. The values - * still have to exist in the Trigger.dev environment; syncing only keeps the - * worker's view of them aligned with the app's. - */ -const FUNCTION_EXECUTION_ENV = [ - { name: 'REDIS_URL', secret: true }, - { name: 'REDIS_TLS_SERVERNAME', secret: false }, - { name: 'SANDBOX_PROVIDER', secret: false }, - { name: 'E2B_ENABLED', secret: false }, - { name: 'E2B_API_KEY', secret: true }, - { name: 'E2B_FUNCTION_TEMPLATE_ID', secret: false }, - { name: 'E2B_FUNCTION_TEMPLATE_GENERATION', secret: false }, - { name: 'MOTHERSHIP_E2B_DOC_TEMPLATE_ID', secret: false }, - { name: 'DAYTONA_API_KEY', secret: true }, - { name: 'DAYTONA_FUNCTION_SNAPSHOT_ID', secret: false }, - { name: 'DAYTONA_DOC_SNAPSHOT_ID', secret: false }, - { name: 'S3_COPILOT_BUCKET_NAME', secret: false }, - { name: 'AZURE_STORAGE_COPILOT_CONTAINER_NAME', secret: false }, - { name: 'GCS_COPILOT_BUCKET_NAME', secret: false }, -] as const - -function getFunctionExecutionEnvVars() { - return FUNCTION_EXECUTION_ENV.flatMap(({ name, secret }) => { - const value = env[name] - return value ? [{ name, value, isSecret: secret }] : [] - }) -} - const grafanaTelemetry = grafanaFullyConfigured ? (() => { const baseUrl = grafanaEndpoint!.replace(/\/+$/, '') @@ -134,17 +98,7 @@ export default defineConfig({ '@napi-rs/canvas', ], extensions: [ - syncEnvVars(() => [ - { name: 'DB_APP_NAME', value: 'sim-trigger' }, - /** - * Workers run Trigger.dev by definition, but the flag saying so was only - * set on the app container. Syncing it keeps the deployment flag honest - * inside runs; the dispatch decision itself no longer depends on it, - * because the `init` hook above marks the run process directly. - */ - { name: 'TRIGGER_DEV_ENABLED', value: 'TRUE' }, - ...getFunctionExecutionEnvVars(), - ]), + syncEnvVars(syncWorkerEnvironment), additionalFiles({ files: [ './lib/execution/isolated-vm-worker.cjs',