Skip to content

Commit 5453ba0

Browse files
waleedlatif1claude
andcommitted
fix(trigger): clear worker vars the secret drops, and allow strict sync
Two review findings on the worker env sync. Skipping a key the authoritative secret no longer carries left the worker's previous value in place, so deleting a compromised `E2B_API_KEY`, `DAYTONA_API_ KEY` or `REDIS_URL` from Secrets Manager did not revoke it in the worker: the app stopped loading the credential while runs kept using it. Absent keys are now published as `''`. Every key in the list is read as a truthiness or `||` check and none uses `??`, so `''` is indistinguishable from unset at the read sites. Nothing is cleared when the read failed or the environment is unmapped — without a successful read there is no authority to clear against. A failed lookup still degrades to the constants by default, because the build credentials do not exist on the staging and prod deploy paths yet and failing there would break every deploy the moment this lands. `SIM_TRIGGER_ENV_SYNC_ REQUIRED` makes that case fail instead, so the rollout can finish and then close the hole for good. `syncEnvVars` swallows a rejected callback and continues having published nothing, so rejecting cannot fail a deploy on its own — the config turns the error into a non-zero exit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BBuD7nBqVURAah8t6jfND
1 parent f4d5a3e commit 5453ba0

3 files changed

Lines changed: 98 additions & 39 deletions

File tree

apps/sim/lib/core/config/trigger-env-sync.test.ts

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
55

66
const { mockFetchSecretMap } = vi.hoisted(() => ({ mockFetchSecretMap: vi.fn() }))
77

@@ -11,6 +11,7 @@ import {
1111
assertSyncableKeys,
1212
resolveTriggerEnvVars,
1313
SECRET_ID_BY_ENVIRONMENT,
14+
TriggerEnvSyncUnavailableError,
1415
WORKER_SECRET_KEYS,
1516
} from '@/lib/core/config/trigger-env-sync'
1617

@@ -23,6 +24,11 @@ function byName(vars: { name: string; value: string; isSecret: boolean }[]) {
2324
describe('resolveTriggerEnvVars', () => {
2425
beforeEach(() => {
2526
vi.clearAllMocks()
27+
process.env.SIM_TRIGGER_ENV_SYNC_REQUIRED = undefined
28+
})
29+
30+
afterEach(() => {
31+
process.env.SIM_TRIGGER_ENV_SYNC_REQUIRED = undefined
2632
})
2733

2834
it('reads the secret mapped to the environment', async () => {
@@ -65,23 +71,46 @@ describe('resolveTriggerEnvVars', () => {
6571
expect(resolved.get('DB_APP_NAME')?.isSecret).toBe(false)
6672
})
6773

68-
it('omits keys the secret does not carry, and keeps the ones it does', async () => {
74+
it('clears a key the authoritative secret no longer carries, so a revoked credential cannot survive in the worker', async () => {
6975
const resolved = byName(
7076
await resolveTriggerEnvVars('staging', async () => ({ REDIS_URL: 'redis://host' }))
7177
)
7278

7379
expect(resolved.get('REDIS_URL')?.value).toBe('redis://host')
74-
expect(resolved.has('E2B_API_KEY')).toBe(false)
75-
expect(resolved.size).toBe(CONSTANTS.length + 1)
80+
expect(resolved.get('E2B_API_KEY')?.value).toBe('')
81+
expect(resolved.get('DAYTONA_API_KEY')?.value).toBe('')
82+
expect(resolved.size).toBe(CONSTANTS.length + WORKER_SECRET_KEYS.length)
7683
})
7784

78-
it('treats an empty value as unset so it cannot blank a working dashboard value', async () => {
85+
it('clears a key blanked or nulled in the secret rather than leaving the old value', async () => {
7986
const resolved = byName(
8087
await resolveTriggerEnvVars('staging', async () => ({ REDIS_URL: '', E2B_API_KEY: null }))
8188
)
8289

83-
expect(resolved.has('REDIS_URL')).toBe(false)
84-
expect(resolved.has('E2B_API_KEY')).toBe(false)
90+
expect(resolved.get('REDIS_URL')?.value).toBe('')
91+
expect(resolved.get('E2B_API_KEY')?.value).toBe('')
92+
})
93+
94+
it('clears nothing when the secret could not be read, having no authority to clear against', async () => {
95+
const resolved = await resolveTriggerEnvVars('prod', async () => {
96+
throw new Error('AccessDeniedException')
97+
})
98+
99+
expect(resolved.map((v) => v.name)).toEqual(CONSTANTS)
100+
})
101+
102+
it('fails the resolve instead of publishing a partial env when sync is required', async () => {
103+
process.env.SIM_TRIGGER_ENV_SYNC_REQUIRED = '1'
104+
105+
await expect(
106+
resolveTriggerEnvVars('prod', async () => {
107+
throw new Error('AccessDeniedException')
108+
})
109+
).rejects.toBeInstanceOf(TriggerEnvSyncUnavailableError)
110+
111+
await expect(resolveTriggerEnvVars('dev', vi.fn())).rejects.toBeInstanceOf(
112+
TriggerEnvSyncUnavailableError
113+
)
85114
})
86115

87116
it('serializes a non-string secret entry', async () => {

apps/sim/lib/core/config/trigger-env-sync.ts

Lines changed: 49 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,18 @@ export const WORKER_SECRET_KEYS: readonly { name: string; secret: boolean }[] =
6464
{ name: 'GCS_COPILOT_BUCKET_NAME', secret: false },
6565
] as const
6666

67+
/**
68+
* Set on a build to make an unusable secret fail the deploy instead of leaving
69+
* the worker environment as the previous deploy left it. Off by default so the
70+
* rollout can land before the build credentials exist; turn it on once every
71+
* deploy path can reach Secrets Manager, and a silent lookup failure becomes
72+
* impossible from then on.
73+
*/
74+
const REQUIRED_ENV = 'SIM_TRIGGER_ENV_SYNC_REQUIRED'
75+
76+
/** Thrown only when {@link REQUIRED_ENV} is set. See `trigger.config.ts`. */
77+
export class TriggerEnvSyncUnavailableError extends Error {}
78+
6779
/**
6880
* Secret backing each Trigger.dev deploy target. `preview` is the `dev-sim`
6981
* branch CI deploys from the `dev` branch, so it reads the dev environment's
@@ -82,13 +94,19 @@ export const SECRET_ID_BY_ENVIRONMENT: Readonly<Record<string, string>> = {
8294
* them from the same Secrets Manager entry the app container boots from, so the
8395
* two runtimes cannot drift.
8496
*
85-
* Never throws. `syncEnvVars` swallows a callback rejection and then publishes
86-
* *nothing*, which would silently drop the constants too, so a failed or
87-
* incomplete lookup degrades to publishing what is known rather than aborting.
88-
* Keys the secret does not carry are logged by name and simply not published —
89-
* several are optional (Azure and GCS buckets on an S3 deployment, for one), so
90-
* their absence is normal rather than an error, and leaving them out preserves
91-
* whatever the Trigger.dev environment already held.
97+
* The secret is authoritative for every {@link WORKER_SECRET_KEYS} entry, so a
98+
* key it does not carry is published as `''` rather than skipped. Skipping left
99+
* the worker's previous value in place, which meant deleting a compromised
100+
* `E2B_API_KEY` from the secret did not revoke it in the worker — the app
101+
* stopped loading it while runs kept using it. Every one of these keys is read
102+
* as a truthiness or `||` check (never `??`), so `''` behaves exactly as unset.
103+
*
104+
* Throws only when {@link REQUIRED_ENV} is set. Otherwise a failed or unmapped
105+
* lookup degrades to the environment-independent constants: `syncEnvVars`
106+
* swallows a callback rejection and then publishes *nothing*, so rejecting by
107+
* default would drop the constants too and still not fail the deploy. Nothing
108+
* is cleared on that path — without a successful read there is no authority to
109+
* clear against, and the Trigger.dev environment is left exactly as it was.
92110
*
93111
* @param environment Trigger.dev deploy target (`prod`, `staging`, `preview`).
94112
* @param loadSecret Secret reader, injectable for tests.
@@ -99,52 +117,52 @@ export async function resolveTriggerEnvVars(
99117
): Promise<SyncedEnvVar[]> {
100118
const secretId = SECRET_ID_BY_ENVIRONMENT[environment]
101119
if (!secretId) {
102-
logger.warn(
103-
`No secret mapped for Trigger.dev environment "${environment}"; publishing constants only`
104-
)
105-
return [...CONSTANT_ENV]
120+
return unavailable(`No secret is mapped for Trigger.dev environment "${environment}"`)
106121
}
107122

108123
let entries: Record<string, unknown>
109124
try {
110125
entries = await loadSecret(secretId)
111126
} catch (error) {
112-
logger.error(
113-
`Failed to read ${secretId}; publishing constants only. Worker env is unchanged from the previous deploy.`,
114-
{ error: getErrorMessage(error) }
115-
)
116-
return [...CONSTANT_ENV]
127+
return unavailable(`Failed to read ${secretId}: ${getErrorMessage(error)}`)
117128
}
118129

119130
const resolved: SyncedEnvVar[] = [...CONSTANT_ENV]
120-
const missing: string[] = []
131+
const cleared: string[] = []
121132

122133
for (const { name, secret } of WORKER_SECRET_KEYS) {
123134
const value = normalizeSecretValue(entries[name])
124-
if (value === undefined) {
125-
missing.push(name)
126-
continue
127-
}
128-
resolved.push({ name, value, isSecret: secret })
129-
}
130-
131-
if (missing.length > 0) {
132-
logger.info(
133-
`${missing.length} worker env var(s) not set in ${secretId}; they are left untouched in Trigger.dev`,
134-
{ missing }
135-
)
135+
if (value === undefined) cleared.push(name)
136+
resolved.push({ name, value: value ?? '', isSecret: secret })
136137
}
137138

138139
logger.info('Resolved Trigger.dev env vars', {
139140
environment,
140141
secretId,
141-
published: resolved.length,
142-
missing: missing.length,
142+
published: resolved.length - cleared.length,
143+
cleared,
143144
})
144145

145146
return resolved
146147
}
147148

149+
/**
150+
* Handles a resolve that has no authoritative view of the environment, honoring
151+
* {@link REQUIRED_ENV}.
152+
*/
153+
function unavailable(reason: string): SyncedEnvVar[] {
154+
if (process.env[REQUIRED_ENV]) {
155+
throw new TriggerEnvSyncUnavailableError(
156+
`${reason}. ${REQUIRED_ENV} is set, so this deploy must not publish a partial worker environment.`
157+
)
158+
}
159+
160+
logger.error(
161+
`${reason}. Publishing constants only; the worker environment is unchanged from the previous deploy. Set ${REQUIRED_ENV} to fail the deploy instead.`
162+
)
163+
return [...CONSTANT_ENV]
164+
}
165+
148166
/**
149167
* Coerces a secret entry to an env var value, matching how container boot
150168
* hydrates `process.env`. An absent or empty value is treated as unset so a

apps/sim/trigger.config.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'
22
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'
33
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
44
import { resourceFromAttributes } from '@opentelemetry/resources'
5+
import { getErrorMessage } from '@sim/utils/errors'
56
import {
67
additionalFiles,
78
additionalPackages,
@@ -101,7 +102,18 @@ export default defineConfig({
101102
* prod` would have published the developer's own `.env` into production
102103
* (`syncEnvVars` applies its layer with `override: true`).
103104
*/
104-
syncEnvVars(({ environment }) => resolveTriggerEnvVars(environment)),
105+
syncEnvVars(async ({ environment }) => {
106+
try {
107+
return await resolveTriggerEnvVars(environment)
108+
} catch (error) {
109+
// `syncEnvVars` catches a rejected callback, warns, and lets the
110+
// deploy continue having published nothing — so rejecting is not a
111+
// way to fail. Only an explicit non-zero exit is, and this path is
112+
// reached only when SIM_TRIGGER_ENV_SYNC_REQUIRED asked for it.
113+
console.error(getErrorMessage(error))
114+
process.exit(1)
115+
}
116+
}),
105117
additionalFiles({
106118
files: [
107119
'./lib/execution/isolated-vm-worker.cjs',

0 commit comments

Comments
 (0)