Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .claude/rules/sim-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,17 @@ Every export of a `'use client'` module becomes a *client reference* on the serv
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.
dashboard, plus whatever `trigger.config.ts` publishes at deploy time: `DB_APP_NAME` and the
`WORKER_SECRET_KEYS` list in `lib/core/config/trigger-env-sync.ts`, read from the *same*
`/{env}/sim/env-vars` secret the app boots from. To give workers a new variable, add its key to
that list and set it in the secret. The repo still cannot see what else the dashboard holds.

Two constraints on that list. `syncEnvVars` strips every `TRIGGER_`-prefixed key before it
publishes, so such a variable can only be set in the dashboard (`assertSyncableKeys` fails the
build rather than letting one look synced). And the secret is authoritative: after a successful
read, a key it does not carry — absent, `null`, or blank — is published as `''`, so deleting a
credential from the secret revokes it in the worker too. Nothing is cleared when the read failed
or the environment is unmapped, since there is then no authoritative view to clear against.

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
Expand Down
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -233,10 +233,23 @@ jobs:
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 15
permissions:
contents: read
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6

# syncEnvVars reads /dev/sim/env-vars from Secrets Manager during the
# build to publish the worker environment. Without these credentials the
# deploy still succeeds, but publishes only the environment-independent
# constants and leaves the worker env at whatever the previous deploy set.
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6
with:
role-to-assume: ${{ secrets.DEV_AWS_ROLE_TO_ASSUME }}
aws-region: ${{ secrets.DEV_AWS_REGION }}

- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
Expand Down
154 changes: 154 additions & 0 deletions apps/sim/lib/core/config/trigger-env-sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'

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

vi.mock('@sim/runtime-secrets', () => ({ fetchSecretMap: mockFetchSecretMap }))

import {
assertSyncableKeys,
resolveTriggerEnvVars,
SECRET_ID_BY_ENVIRONMENT,
TriggerEnvSyncUnavailableError,
WORKER_SECRET_KEYS,
} from '@/lib/core/config/trigger-env-sync'

const CONSTANTS = ['DB_APP_NAME']

function byName(vars: { name: string; value: string; isSecret: boolean }[]) {
return new Map(vars.map((v) => [v.name, v]))
}

describe('resolveTriggerEnvVars', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('reads the secret mapped to the environment', async () => {
mockFetchSecretMap.mockResolvedValue({})

await resolveTriggerEnvVars('prod')

expect(mockFetchSecretMap).toHaveBeenCalledWith('/production/sim/env-vars')
})

it.each([
['prod', '/production/sim/env-vars'],
['staging', '/staging/sim/env-vars'],
['preview', '/dev/sim/env-vars'],
])('maps %s to %s', (environment, secretId) => {
expect(SECRET_ID_BY_ENVIRONMENT[environment]).toBe(secretId)
})

it('publishes the constants plus every key present in the secret', async () => {
const secret = Object.fromEntries(WORKER_SECRET_KEYS.map(({ name }) => [name, `${name}-value`]))
const resolved = byName(await resolveTriggerEnvVars('staging', async () => secret))

for (const key of CONSTANTS) expect(resolved.has(key)).toBe(true)
for (const { name } of WORKER_SECRET_KEYS) {
expect(resolved.get(name)?.value).toBe(`${name}-value`)
}
expect(resolved.size).toBe(CONSTANTS.length + WORKER_SECRET_KEYS.length)
})

it('carries the secret flag through from the key table', async () => {
const resolved = byName(
await resolveTriggerEnvVars('staging', async () => ({
REDIS_URL: 'redis://host',
SANDBOX_PROVIDER: 'e2b',
}))
)

expect(resolved.get('REDIS_URL')?.isSecret).toBe(true)
expect(resolved.get('SANDBOX_PROVIDER')?.isSecret).toBe(false)
expect(resolved.get('DB_APP_NAME')?.isSecret).toBe(false)
})

it('clears a key the authoritative secret no longer carries, so a revoked credential cannot survive in the worker', async () => {
const resolved = byName(
await resolveTriggerEnvVars('staging', async () => ({ REDIS_URL: 'redis://host' }))
)

expect(resolved.get('REDIS_URL')?.value).toBe('redis://host')
expect(resolved.get('E2B_API_KEY')?.value).toBe('')
expect(resolved.get('DAYTONA_API_KEY')?.value).toBe('')
expect(resolved.size).toBe(CONSTANTS.length + WORKER_SECRET_KEYS.length)
})

it('clears a key blanked or nulled in the secret rather than leaving the old value', async () => {
const resolved = byName(
await resolveTriggerEnvVars('staging', async () => ({ REDIS_URL: '', E2B_API_KEY: null }))
)

expect(resolved.get('REDIS_URL')?.value).toBe('')
expect(resolved.get('E2B_API_KEY')?.value).toBe('')
})

it('clears nothing when the secret could not be read, having no authority to clear against', async () => {
const resolved = await resolveTriggerEnvVars('prod', async () => {
throw new Error('AccessDeniedException')
})

expect(resolved.map((v) => v.name)).toEqual(CONSTANTS)
})

it('fails the resolve instead of publishing a partial env when sync is required', async () => {
vi.stubEnv('SIM_TRIGGER_ENV_SYNC_REQUIRED', '1')

await expect(
resolveTriggerEnvVars('prod', async () => {
throw new Error('AccessDeniedException')
})
).rejects.toBeInstanceOf(TriggerEnvSyncUnavailableError)

await expect(resolveTriggerEnvVars('dev', vi.fn())).rejects.toBeInstanceOf(
TriggerEnvSyncUnavailableError
)
})

it('serializes a non-string secret entry', async () => {
const resolved = byName(
await resolveTriggerEnvVars('staging', async () => ({ E2B_ENABLED: true }))
)

expect(resolved.get('E2B_ENABLED')?.value).toBe('true')
})

it('publishes constants only for an unmapped environment, without reading a secret', async () => {
const loadSecret = vi.fn()

const resolved = await resolveTriggerEnvVars('dev', loadSecret)

expect(loadSecret).not.toHaveBeenCalled()
expect(resolved.map((v) => v.name)).toEqual(CONSTANTS)
})

it('falls back to constants instead of rejecting when the secret cannot be read', async () => {
const resolved = await resolveTriggerEnvVars('prod', async () => {
throw new Error('AccessDeniedException')
})

expect(resolved.map((v) => v.name)).toEqual(CONSTANTS)
})

it('publishes no TRIGGER_-prefixed key, which the sync layer would strip silently', async () => {
const resolved = await resolveTriggerEnvVars('staging', async () => ({
REDIS_URL: 'redis://host',
}))

expect(resolved.filter((v) => v.name.startsWith('TRIGGER_'))).toEqual([])
expect(WORKER_SECRET_KEYS.filter(({ name }) => name.startsWith('TRIGGER_'))).toEqual([])
})

it('rejects a key the sync layer would strip, naming it', () => {
expect(() => assertSyncableKeys(['DB_APP_NAME', 'TRIGGER_DEV_ENABLED'])).toThrow(
/TRIGGER_DEV_ENABLED/
)
})

it('accepts the keys this module actually publishes', () => {
expect(() => assertSyncableKeys()).not.toThrow()
})
})
Loading
Loading