Skip to content

Commit fa2cc10

Browse files
committed
fix(managed-agent): authenticate defaults API + wire up key rotation UI
Two follow-up fixes for Cursor Bugbot's most recent review. #11 Defaults API lacks authentication - `GET /api/managed-agent-defaults` returned every value from `MANAGED_AGENT_SELF_HOSTED_DEFAULTS` with no session check. Docs and the block copy steer deployers toward putting sandbox-scoped secrets there (because they never enter the client bundle at build time), but an unauthenticated caller could still dump the JSON. - Fixed: route now runs `checkSessionOrInternalAuth` and returns 401 for anonymous callers. Same-origin fetches from the block editor automatically carry the session cookie, so no client change needed. - Test added: `route.test.ts` now covers the 401 unauthenticated case. #13 `useRotateManagedAgentConnection` was unused - The hook + PATCH route existed but nothing called them, so the only way to change a stored API key was delete + re-link — awkward when the deployer's just cycling the key. - Wired into the settings UI: - New `RotateKeyModal` component (mirrors the create modal but with only the key field; connection name stays put) runs the same verify-then-save flow. - Added a "Rotate key" action to each connection row's `RowActionsMenu`, gated on `canEdit` (workspace `write`/`admin` tiers). Success shows a toast; errors surface inline in the modal. Not fixed / addressed inline: - #9 (stale labels after connection change) and #10 (empty table re-seeds defaults) — Bugbot re-fired these on the previous commit by signature-match against a static code shape. The actual fixes (extra sibling-dep comparison in `areSubBlockRowPropsEqual`; only seed when `storeValue` is null/undefined) are in place and functional. No change needed. - #12 (empty table shows a phantom row) — theoretical. The table component's delete-row guard (`rows.length === 1`) prevents users from ever reaching `[]` through the UI, so the mismatch it warns about isn't reachable in normal use.
1 parent f42a13a commit fa2cc10

4 files changed

Lines changed: 156 additions & 6 deletions

File tree

apps/sim/app/api/managed-agent-defaults/route.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
import { createMockRequest } from '@sim/testing'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66

7-
const { envState } = vi.hoisted(() => ({
7+
const { envState, mockCheckSessionOrInternalAuth } = vi.hoisted(() => ({
88
envState: { MANAGED_AGENT_SELF_HOSTED_DEFAULTS: undefined as string | undefined },
9+
mockCheckSessionOrInternalAuth: vi.fn(),
910
}))
1011

1112
vi.mock('@/lib/core/config/env', () => ({
@@ -14,11 +15,22 @@ vi.mock('@/lib/core/config/env', () => ({
1415
}),
1516
}))
1617

18+
vi.mock('@/lib/auth/hybrid', () => ({
19+
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
20+
}))
21+
1722
import { GET } from '@/app/api/managed-agent-defaults/route'
1823

1924
describe('GET /api/managed-agent-defaults', () => {
2025
beforeEach(() => {
2126
envState.MANAGED_AGENT_SELF_HOSTED_DEFAULTS = undefined
27+
mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: 'user_1' })
28+
})
29+
30+
it('returns 401 for an unauthenticated caller', async () => {
31+
mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false })
32+
const res = await GET(createMockRequest('GET'))
33+
expect(res.status).toBe(401)
2234
})
2335

2436
it('returns an empty list when the env var is unset', async () => {

apps/sim/app/api/managed-agent-defaults/route.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
3+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
34
import { env } from '@/lib/core/config/env'
45
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
56

@@ -42,11 +43,15 @@ function readSelfHostedDefaults(): DefaultRow[] {
4243
* GET /api/managed-agent-defaults
4344
*
4445
* Returns the deployer-configured default rows the block picker seeds
45-
* into a fresh Claude Managed Agents (self-hosted) block. No auth
46-
* required — the response contains only default keys the deployer
47-
* chose to expose to their workflow authors; there is no per-user or
48-
* per-workspace state.
46+
* into a fresh Claude Managed Agents (self-hosted) block. Authenticated
47+
* because the env var may legitimately contain sandbox-scoped secrets
48+
* (the whole point of moving it off `NEXT_PUBLIC_*` is that values
49+
* should not be exposed to unauthenticated callers).
4950
*/
50-
export const GET = withRouteHandler(async (_request: NextRequest) => {
51+
export const GET = withRouteHandler(async (request: NextRequest) => {
52+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
53+
if (!authResult.success || !authResult.userId) {
54+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
55+
}
5156
return NextResponse.json({ selfHosted: readSelfHostedDefaults() }, { status: 200 })
5257
})

apps/sim/app/workspace/[workspaceId]/settings/components/managed-agents/managed-agents.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/compo
1313
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
1414
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
1515
import { ConnectionFormModal } from '@/app/workspace/[workspaceId]/settings/components/managed-agents/connection-form-modal'
16+
import { RotateKeyModal } from '@/app/workspace/[workspaceId]/settings/components/managed-agents/rotate-key-modal'
1617
import {
1718
useCreateManagedAgentConnection,
1819
useDeleteManagedAgentConnection,
1920
useManagedAgentConnections,
21+
useRotateManagedAgentConnection,
2022
} from '@/hooks/queries/managed-agent-connections'
2123

2224
const logger = createLogger('ManagedAgentsSettings')
@@ -34,10 +36,15 @@ export function ManagedAgents() {
3436
} = useManagedAgentConnections(workspaceId)
3537
const createConnection = useCreateManagedAgentConnection()
3638
const deleteConnection = useDeleteManagedAgentConnection()
39+
const rotateConnection = useRotateManagedAgentConnection()
3740

3841
const [searchTerm, setSearchTerm] = useSettingsSearch()
3942
const [showAddModal, setShowAddModal] = useState(false)
4043
const [connectionToDeleteId, setConnectionToDeleteId] = useState<string | null>(null)
44+
const [connectionToRotate, setConnectionToRotate] = useState<{
45+
id: string
46+
name: string
47+
} | null>(null)
4148

4249
const filtered = connections.filter((c) => {
4350
if (!searchTerm.trim()) return true
@@ -68,6 +75,16 @@ export function ManagedAgents() {
6875
}
6976
}
7077

78+
const handleRotate = async (apiKey: string) => {
79+
if (!connectionToRotate) return
80+
await rotateConnection.mutateAsync({
81+
workspaceId,
82+
id: connectionToRotate.id,
83+
apiKey,
84+
})
85+
toast.success('API key rotated')
86+
}
87+
7188
const hasConnections = connections.length > 0
7289
const showNoResults = searchTerm.trim() && filtered.length === 0 && hasConnections
7390

@@ -137,6 +154,11 @@ export function ManagedAgents() {
137154
actions={[
138155
...(canEdit
139156
? [
157+
{
158+
label: 'Rotate key',
159+
onSelect: () =>
160+
setConnectionToRotate({ id: c.id, name: c.name }),
161+
},
140162
{
141163
label: 'Remove',
142164
destructive: true,
@@ -181,6 +203,17 @@ export function ManagedAgents() {
181203
confirm={{ label: 'Remove', onClick: confirmDelete }}
182204
/>
183205
)}
206+
207+
{canEdit && (
208+
<RotateKeyModal
209+
open={connectionToRotate !== null}
210+
connectionName={connectionToRotate?.name ?? null}
211+
onOpenChange={(open) => {
212+
if (!open) setConnectionToRotate(null)
213+
}}
214+
onSubmit={handleRotate}
215+
/>
216+
)}
184217
</>
185218
)
186219
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import {
5+
ChipModal,
6+
ChipModalBody,
7+
ChipModalError,
8+
ChipModalField,
9+
ChipModalFooter,
10+
ChipModalHeader,
11+
} from '@sim/emcn'
12+
import { getErrorMessage } from '@sim/utils/errors'
13+
14+
interface RotateKeyModalProps {
15+
open: boolean
16+
connectionName: string | null
17+
onOpenChange: (open: boolean) => void
18+
onSubmit: (apiKey: string) => Promise<void>
19+
}
20+
21+
/**
22+
* "Rotate API key" dialog. Same verify-then-save flow as the initial
23+
* link modal, but only the key field is editable — the connection name
24+
* stays put. Kept separate from the create modal so the wording and the
25+
* required fields match the operation the user is doing.
26+
*/
27+
export function RotateKeyModal({
28+
open,
29+
connectionName,
30+
onOpenChange,
31+
onSubmit,
32+
}: RotateKeyModalProps) {
33+
const [apiKey, setApiKey] = useState('')
34+
const [submitting, setSubmitting] = useState(false)
35+
const [error, setError] = useState<string | null>(null)
36+
const [prevOpen, setPrevOpen] = useState(false)
37+
38+
if (open !== prevOpen) {
39+
setPrevOpen(open)
40+
if (open) {
41+
setApiKey('')
42+
setError(null)
43+
setSubmitting(false)
44+
}
45+
}
46+
47+
const handleSubmit = async () => {
48+
const trimmedKey = apiKey.trim()
49+
if (!trimmedKey) {
50+
setError('Anthropic API key is required.')
51+
return
52+
}
53+
setSubmitting(true)
54+
setError(null)
55+
try {
56+
await onSubmit(trimmedKey)
57+
onOpenChange(false)
58+
} catch (err) {
59+
setError(getErrorMessage(err, 'Failed to rotate the API key.'))
60+
} finally {
61+
setSubmitting(false)
62+
}
63+
}
64+
65+
const title = connectionName ? `Rotate key — ${connectionName}` : 'Rotate API key'
66+
67+
return (
68+
<ChipModal open={open} onOpenChange={onOpenChange} srTitle={title} size='md'>
69+
<ChipModalHeader onClose={() => onOpenChange(false)}>{title}</ChipModalHeader>
70+
71+
<ChipModalBody>
72+
<ChipModalField
73+
type='input'
74+
title='New Anthropic API key'
75+
value={apiKey}
76+
onChange={(value) => {
77+
setApiKey(value)
78+
if (error) setError(null)
79+
}}
80+
placeholder='sk-ant-…'
81+
password
82+
required
83+
hint='Verified against /v1/agents before saving. The old key is overwritten on success — existing workflows immediately use the new one.'
84+
/>
85+
86+
<ChipModalError>{error ?? undefined}</ChipModalError>
87+
</ChipModalBody>
88+
89+
<ChipModalFooter
90+
onCancel={() => onOpenChange(false)}
91+
cancelDisabled={submitting}
92+
primaryAction={{
93+
label: submitting ? 'Verifying…' : 'Rotate & save',
94+
onClick: handleSubmit,
95+
disabled: submitting || !apiKey.trim(),
96+
}}
97+
/>
98+
</ChipModal>
99+
)
100+
}

0 commit comments

Comments
 (0)