Skip to content

Commit ea905bb

Browse files
author
Ricardo DeMatos
committed
fix(managed-agent): proxy routes no longer 500 on decrypt failure
Followup on PR #5769 review: Cursor Bugbot flagged that `getDecryptedApiKey` still throws on ciphertext decrypt failure, so the five proxy routes (agents / environments / environment-detail / vaults / memory-stores) surface a 500 to the client instead of a controlled response. My prior tool-level try/catch only handled the workflow-block invocation path. Change: `getDecryptedApiKey` now returns a discriminated result — `{ ok: true, apiKey } | { ok: false, reason: 'not_found' | 'decrypt_failed' }` — so callers can render each failure mode with the correct status: - `not_found` → 404 "Connection not found" (unchanged) - `decrypt_failed` → 502 with an actionable "rotate the API key to re-encrypt" hint, matching the tool-side message New `apiKeyFailureResponse` helper in `lib/managed-agents/proxy.ts` keeps the mapping in one place — each proxy route now reads: const keyResult = await getDecryptedApiKey({ id, workspaceId }) if (!keyResult.ok) return apiKeyFailureResponse(keyResult) const apiKey = keyResult.apiKey Tool call site (`run_session.server.ts`) collapses its previous try/catch to the same `keyResult.ok` check — cleaner and shares the same error phrasing. Tests updated: `getDecryptedApiKey` cases now assert the shape; added a third case that mocks `decryptSecret` to reject and asserts `{ ok: false, reason: 'decrypt_failed' }`. All 87 tests pass.
1 parent 95037b3 commit ea905bb

9 files changed

Lines changed: 89 additions & 52 deletions

File tree

apps/sim/app/api/managed-agent-connections/[id]/agents/route.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1212
import { getDecryptedApiKey } from '@/lib/managed-agents/connections'
1313
import {
1414
type AnthropicListPage,
15+
apiKeyFailureResponse,
1516
ManagedAgentProxyError,
1617
proxyManagedAgentsGet,
1718
} from '@/lib/managed-agents/proxy'
@@ -56,10 +57,9 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RouteC
5657
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
5758
}
5859

59-
const apiKey = await getDecryptedApiKey({ id, workspaceId })
60-
if (!apiKey) {
61-
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
62-
}
60+
const keyResult = await getDecryptedApiKey({ id, workspaceId })
61+
if (!keyResult.ok) return apiKeyFailureResponse(keyResult)
62+
const apiKey = keyResult.apiKey
6363

6464
try {
6565
const body = await proxyManagedAgentsGet<AnthropicListPage<AnthropicAgent>>(

apps/sim/app/api/managed-agent-connections/[id]/environments/[envId]/route.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
1111
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1212
import { getDecryptedApiKey } from '@/lib/managed-agents/connections'
1313
import {
14+
apiKeyFailureResponse,
1415
ManagedAgentProxyError,
1516
proxyManagedAgentsGet,
1617
} from '@/lib/managed-agents/proxy'
@@ -60,10 +61,9 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RouteC
6061
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
6162
}
6263

63-
const apiKey = await getDecryptedApiKey({ id, workspaceId })
64-
if (!apiKey) {
65-
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
66-
}
64+
const keyResult = await getDecryptedApiKey({ id, workspaceId })
65+
if (!keyResult.ok) return apiKeyFailureResponse(keyResult)
66+
const apiKey = keyResult.apiKey
6767

6868
try {
6969
const body = await proxyManagedAgentsGet<AnthropicEnvironment>(

apps/sim/app/api/managed-agent-connections/[id]/environments/route.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1212
import { getDecryptedApiKey } from '@/lib/managed-agents/connections'
1313
import {
1414
type AnthropicListPage,
15+
apiKeyFailureResponse,
1516
ManagedAgentProxyError,
1617
proxyManagedAgentsGet,
1718
} from '@/lib/managed-agents/proxy'
@@ -56,10 +57,9 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RouteC
5657
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
5758
}
5859

59-
const apiKey = await getDecryptedApiKey({ id, workspaceId })
60-
if (!apiKey) {
61-
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
62-
}
60+
const keyResult = await getDecryptedApiKey({ id, workspaceId })
61+
if (!keyResult.ok) return apiKeyFailureResponse(keyResult)
62+
const apiKey = keyResult.apiKey
6363

6464
try {
6565
const body = await proxyManagedAgentsGet<AnthropicListPage<AnthropicEnvironment>>(

apps/sim/app/api/managed-agent-connections/[id]/memory-stores/route.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
1111
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1212
import { getDecryptedApiKey } from '@/lib/managed-agents/connections'
1313
import {
14+
apiKeyFailureResponse,
1415
ManagedAgentProxyError,
1516
proxyManagedAgentsGet,
1617
} from '@/lib/managed-agents/proxy'
@@ -69,10 +70,9 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RouteC
6970
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
7071
}
7172

72-
const apiKey = await getDecryptedApiKey({ id, workspaceId })
73-
if (!apiKey) {
74-
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
75-
}
73+
const keyResult = await getDecryptedApiKey({ id, workspaceId })
74+
if (!keyResult.ok) return apiKeyFailureResponse(keyResult)
75+
const apiKey = keyResult.apiKey
7676

7777
try {
7878
const body = await proxyManagedAgentsGet<AnthropicMemoryStorePage>(

apps/sim/app/api/managed-agent-connections/[id]/vaults/route.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1212
import { getDecryptedApiKey } from '@/lib/managed-agents/connections'
1313
import {
1414
type AnthropicListPage,
15+
apiKeyFailureResponse,
1516
ManagedAgentProxyError,
1617
proxyManagedAgentsGet,
1718
} from '@/lib/managed-agents/proxy'
@@ -54,10 +55,9 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RouteC
5455
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
5556
}
5657

57-
const apiKey = await getDecryptedApiKey({ id, workspaceId })
58-
if (!apiKey) {
59-
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
60-
}
58+
const keyResult = await getDecryptedApiKey({ id, workspaceId })
59+
if (!keyResult.ok) return apiKeyFailureResponse(keyResult)
60+
const apiKey = keyResult.apiKey
6161

6262
try {
6363
const body = await proxyManagedAgentsGet<AnthropicListPage<AnthropicVault>>(

apps/sim/lib/managed-agents/connections.test.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -169,19 +169,29 @@ describe('getDecryptedApiKey', () => {
169169
dbState.whereCalls = []
170170
})
171171

172-
it('returns the decrypted plaintext when the connection exists', async () => {
172+
it('returns {ok:true, apiKey} when the connection exists', async () => {
173173
dbState.selectResults = [[baseRow]]
174-
const key = await getDecryptedApiKey({ id: 'conn_1', workspaceId: 'ws_A' })
175-
expect(key).toBe(PLAINTEXT_KEY)
174+
const result = await getDecryptedApiKey({ id: 'conn_1', workspaceId: 'ws_A' })
175+
expect(result).toEqual({ ok: true, apiKey: PLAINTEXT_KEY })
176176
// The DB lookup must include BOTH id and workspaceId (no cross-workspace read).
177177
expect(JSON.stringify(dbState.whereCalls[0])).toContain('conn_1')
178178
expect(JSON.stringify(dbState.whereCalls[0])).toContain('ws_A')
179179
})
180180

181-
it('returns null when the connection does not exist for that workspace', async () => {
181+
it('returns {ok:false, reason:"not_found"} when the connection does not exist', async () => {
182182
dbState.selectResults = [[]]
183-
const key = await getDecryptedApiKey({ id: 'conn_1', workspaceId: 'ws_A' })
184-
expect(key).toBeNull()
183+
const result = await getDecryptedApiKey({ id: 'conn_1', workspaceId: 'ws_A' })
184+
expect(result).toEqual({ ok: false, reason: 'not_found' })
185+
})
186+
187+
it('returns {ok:false, reason:"decrypt_failed"} when the ciphertext cannot be decrypted', async () => {
188+
// Simulates the post-ENCRYPTION_KEY-rotation case: row exists but the
189+
// stored blob no longer decrypts. Callers must render this as an
190+
// actionable 502 rather than a 500.
191+
dbState.selectResults = [[baseRow]]
192+
mockDecryptSecret.mockRejectedValueOnce(new Error('bad auth tag'))
193+
const result = await getDecryptedApiKey({ id: 'conn_1', workspaceId: 'ws_A' })
194+
expect(result).toEqual({ ok: false, reason: 'decrypt_failed' })
185195
})
186196
})
187197

apps/sim/lib/managed-agents/connections.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,19 +86,38 @@ export async function getConnection(params: {
8686
return rows[0] ?? null
8787
}
8888

89+
/**
90+
* Result of {@link getDecryptedApiKey}. Callers must distinguish the two
91+
* failure modes so they can surface the right message:
92+
* - `not_found`: the row does not exist for this workspace — 404.
93+
* - `decrypt_failed`: the row exists but its ciphertext cannot be
94+
* decoded (typically after an `ENCRYPTION_KEY` rotation) — a
95+
* controlled 502 with a "rotate the API key to re-encrypt" hint.
96+
*/
97+
export type DecryptedApiKeyResult =
98+
| { ok: true; apiKey: string }
99+
| { ok: false; reason: 'not_found' | 'decrypt_failed' }
100+
89101
/**
90102
* Decrypts the stored API key for a given connection. Server-side only —
91103
* never expose the return value to the browser. Callers are proxy routes
92-
* and the workflow-block tool.
104+
* and the workflow-block tool. Returns a discriminated result rather
105+
* than throwing so callers can respond with an actionable status code
106+
* (404 vs 502) instead of leaking an unhandled 500.
93107
*/
94108
export async function getDecryptedApiKey(params: {
95109
id: string
96110
workspaceId: string
97-
}): Promise<string | null> {
111+
}): Promise<DecryptedApiKeyResult> {
98112
const row = await getConnection(params)
99-
if (!row) return null
100-
const { decrypted } = await decryptSecret(row.encryptedApiKey)
101-
return decrypted
113+
if (!row) return { ok: false, reason: 'not_found' }
114+
try {
115+
const { decrypted } = await decryptSecret(row.encryptedApiKey)
116+
return { ok: true, apiKey: decrypted }
117+
} catch (err) {
118+
logger.warn(`Failed to decrypt api key for connection ${params.id}`, { error: err })
119+
return { ok: false, reason: 'decrypt_failed' }
120+
}
102121
}
103122

104123
export interface CreateConnectionInput {

apps/sim/lib/managed-agents/proxy.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { createLogger } from '@sim/logger'
2+
import { NextResponse } from 'next/server'
3+
import type { DecryptedApiKeyResult } from '@/lib/managed-agents/connections'
24
import {
35
ANTHROPIC_API_BASE,
46
ANTHROPIC_VERSION,
@@ -58,3 +60,24 @@ export interface AnthropicListPage<T> {
5860
has_more?: boolean
5961
last_id?: string
6062
}
63+
64+
/**
65+
* Map a {@link DecryptedApiKeyResult} failure to the correct HTTP
66+
* response for a proxy route. Keeps the "not_found → 404" and
67+
* "decrypt_failed → 502 + actionable message" mapping in one place so
68+
* every proxy route surfaces the same UX for the same failure mode.
69+
*/
70+
export function apiKeyFailureResponse(
71+
failure: Extract<DecryptedApiKeyResult, { ok: false }>
72+
): NextResponse {
73+
if (failure.reason === 'decrypt_failed') {
74+
return NextResponse.json(
75+
{
76+
error:
77+
'Managed Agent connection could not be decrypted — the workspace encryption key may have rotated. Rotate the API key in Settings → Managed Agents to re-encrypt.',
78+
},
79+
{ status: 502 }
80+
)
81+
}
82+
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
83+
}

apps/sim/tools/managed_agent/run_session.server.ts

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -83,33 +83,18 @@ const impl: ManagedAgentServerImpl = async (
8383
return { success: false, output: {}, error: 'aborted' }
8484
}
8585

86-
let apiKey: string | null
87-
try {
88-
apiKey = await getDecryptedApiKey({ id: params.connection, workspaceId })
89-
} catch (error) {
90-
// The stored ciphertext could not be decrypted — most commonly a rotated
91-
// ENCRYPTION_KEY invalidating an existing row. Translate the exception
92-
// into the tool's declared error shape rather than rejecting the promise
93-
// (which would surface as an unhandled workflow failure).
94-
logger.warn('Failed to decrypt Managed Agent api key', {
95-
workspaceId,
96-
connectionId: params.connection,
97-
error: getErrorMessage(error),
98-
})
86+
const keyResult = await getDecryptedApiKey({ id: params.connection, workspaceId })
87+
if (!keyResult.ok) {
9988
return {
10089
success: false,
10190
output: {},
10291
error:
103-
'Managed Agent connection could not be decrypted — the workspace encryption key may have rotated. Rotate the API key in Settings → Managed Agents to re-encrypt.',
104-
}
105-
}
106-
if (!apiKey) {
107-
return {
108-
success: false,
109-
output: {},
110-
error: 'Managed Agent connection not found. Reconnect the Claude workspace and retry.',
92+
keyResult.reason === 'decrypt_failed'
93+
? 'Managed Agent connection could not be decrypted — the workspace encryption key may have rotated. Rotate the API key in Settings → Managed Agents to re-encrypt.'
94+
: 'Managed Agent connection not found. Reconnect the Claude workspace and retry.',
11195
}
11296
}
97+
const apiKey = keyResult.apiKey
11398

11499
if (isPayloadDebugEnabled()) {
115100
logger.info('Managed agent tool raw params (pre-normalization)', {

0 commit comments

Comments
 (0)