Skip to content

Commit 95037b3

Browse files
author
Ricardo DeMatos
committed
fix(managed-agent): address MR feedback (decrypt escape, cancel, requires_action, public defaults)
Four fixes to PR #5769 review comments. - `getDecryptedApiKey` throws when stored ciphertext cannot be decrypted (typically after an `ENCRYPTION_KEY` rotation). Wrapped the call in the tool's error-handling path so the failure returns the tool's declared `{success:false, output:{}, error:'...'}` shape with an actionable message pointing at Settings → Managed Agents → rotate, rather than an unhandled promise rejection. - Added the workflow's abort signal to `_context.abortSignal` on the `directExecution` path in `executeTool`, so any tool that wants to propagate cancellation can. This is opt-in — existing tools are unaffected. - Read the signal in `run_session.server.ts` and thread it into `createSession`, `sendUserMessage`, `openSessionStream`, `readSSEEvents`, and `listEventsAfter`. Every long-lived HTTP hop now cancels cleanly. - Guard all loop entrances with `signal?.aborted` and translate any abort-caused exception into a clean `{success:false, error:'aborted'}` response — no scary stack traces on cancel. - Server-side / vault-backed MCP tools legitimately hold the session in `requires_action` with no client-visible events until the tool finishes. My previous logic returned a terminal error on the first empty `listEventsAfter` catch-up, failing those workflows before the agent could complete. - New policy: `requires_action` idle is NEVER terminal by itself. If the outer reconnect loop finds no new events, back off (500ms, doubling to 5s max) and re-poll `listEventsAfter`. Only fail after a hard `MAX_REQUIRES_ACTION_WAIT_MS` cap (~5 min) — with a clear error message — or on an explicit `session.status_terminated` / `session.error`. - `EventState` gains `requiresActionEnteredAt` (first entry into the busy stretch) and `currentBackoffMs`; both reset when the session makes progress (non-empty catch-up). Reconnect ceiling raised to 60 since the loop now sleeps between polls. - Deleted `NEXT_PUBLIC_MANAGED_AGENT_SELF_HOSTED_DEFAULTS` — any deployer who accidentally seeded a token into the JSON would leak it to every browser via the client bundle. - Replaced with server-only `MANAGED_AGENT_SELF_HOSTED_DEFAULTS` + `GET /api/managed-agent-defaults` route that returns the parsed rows. Values never enter the client bundle at build time. - Extended the `Table` sub-block component with an optional `fetchDefaultRows` async prop (mirrors the `fetchOptions` pattern used by comboboxes). The block config points at `fetchManagedAgentSelfHostedDefaults` in `subblock-options.ts`, which calls the new API route via `requestJson(contract, ...)`. - Removed the deleted function's tests; added coverage for the new API route (`app/api/managed-agent-defaults/route.test.ts` — 7 tests). - Updated `.env.example` docs to make the safety property explicit. Verified: 86 tests pass across the 6 managed-agent test files. Pre-existing tsc errors (integration-tag enum, environments-route type mismatch, etc.) are unchanged.
1 parent 13cfebe commit 95037b3

14 files changed

Lines changed: 385 additions & 153 deletions

File tree

apps/sim/.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
6464
# LITELLM_API_KEY= # Optional bearer token if your LiteLLM proxy requires auth
6565
# FIREWORKS_API_KEY= # Optional Fireworks AI API key for model listing
6666
# NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS=true # Set when using AWS default credential chain (IAM roles, ECS task roles, IRSA). Hides credential fields in Agent block UI.
67-
# NEXT_PUBLIC_MANAGED_AGENT_SELF_HOSTED_DEFAULTS='{"SOURCE_TYPE":"git","SOURCE_REF":"main"}' # JSON object of default session-metadata rows seeded into fresh Claude Managed Agents (self-hosted) blocks. Deployment-specific; keys map to whatever env vars your self-hosted agent sandbox reads. Absent = empty table.
67+
# MANAGED_AGENT_SELF_HOSTED_DEFAULTS='{"SOURCE_TYPE":"git","SOURCE_REF":"main"}' # Server-only JSON object of default session-metadata rows seeded into fresh Claude Managed Agents (self-hosted) blocks. Deployment-specific; keys map to whatever env vars your self-hosted agent sandbox reads. Absent = empty table. Values are served via GET /api/managed-agent-defaults and never inlined into the client bundle — safe to include values that should not leak to the browser.
6868
# NEXT_PUBLIC_MANAGED_AGENT_SELF_HOSTED_MEMORY_ENABLED=true # Reveal Memory Store + Memory Access fields on the Claude Managed Agents (self-hosted) block. Off by default: Claude self-hosted environments do not currently support the memory-store resource attach on the session API. Enable only if your self-hosted agent sandbox implements memory attach on its side.
6969
# AZURE_OPENAI_ENDPOINT= # Azure OpenAI endpoint (hides field in UI when set alongside NEXT_PUBLIC_AZURE_CONFIGURED)
7070
# AZURE_OPENAI_API_KEY= # Azure OpenAI API key
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { envState } = vi.hoisted(() => ({
8+
envState: { MANAGED_AGENT_SELF_HOSTED_DEFAULTS: undefined as string | undefined },
9+
}))
10+
11+
vi.mock('@/lib/core/config/env', () => ({
12+
env: new Proxy({} as Record<string, unknown>, {
13+
get: (_target, key) => (envState as Record<string, unknown>)[key as string],
14+
}),
15+
}))
16+
17+
import { GET } from '@/app/api/managed-agent-defaults/route'
18+
19+
describe('GET /api/managed-agent-defaults', () => {
20+
beforeEach(() => {
21+
envState.MANAGED_AGENT_SELF_HOSTED_DEFAULTS = undefined
22+
})
23+
24+
it('returns an empty list when the env var is unset', async () => {
25+
const res = await GET(createMockRequest('GET'))
26+
expect(res.status).toBe(200)
27+
const body = await res.json()
28+
expect(body.selfHosted).toEqual([])
29+
})
30+
31+
it('returns an empty list for whitespace-only env values', async () => {
32+
envState.MANAGED_AGENT_SELF_HOSTED_DEFAULTS = ' '
33+
const body = await (await GET(createMockRequest('GET'))).json()
34+
expect(body.selfHosted).toEqual([])
35+
})
36+
37+
it('returns an empty list on invalid JSON', async () => {
38+
envState.MANAGED_AGENT_SELF_HOSTED_DEFAULTS = '{not-json'
39+
const body = await (await GET(createMockRequest('GET'))).json()
40+
expect(body.selfHosted).toEqual([])
41+
})
42+
43+
it('returns an empty list on a JSON array (must be an object)', async () => {
44+
envState.MANAGED_AGENT_SELF_HOSTED_DEFAULTS = '["a","b"]'
45+
const body = await (await GET(createMockRequest('GET'))).json()
46+
expect(body.selfHosted).toEqual([])
47+
})
48+
49+
it('coerces a valid JSON object into table-row shape', async () => {
50+
envState.MANAGED_AGENT_SELF_HOSTED_DEFAULTS = JSON.stringify({
51+
FOO: 'bar',
52+
BAZ: 'qux',
53+
})
54+
const body = await (await GET(createMockRequest('GET'))).json()
55+
expect(body.selfHosted).toEqual([
56+
{ cells: { Key: 'FOO', Value: 'bar' } },
57+
{ cells: { Key: 'BAZ', Value: 'qux' } },
58+
])
59+
})
60+
61+
it('drops entries with a blank key', async () => {
62+
envState.MANAGED_AGENT_SELF_HOSTED_DEFAULTS = JSON.stringify({
63+
'': 'dropped',
64+
' ': 'also dropped',
65+
keep: 'yes',
66+
})
67+
const body = await (await GET(createMockRequest('GET'))).json()
68+
expect(body.selfHosted).toEqual([{ cells: { Key: 'keep', Value: 'yes' } }])
69+
})
70+
71+
it('coerces non-string values to their string form', async () => {
72+
envState.MANAGED_AGENT_SELF_HOSTED_DEFAULTS = JSON.stringify({
73+
A: 1,
74+
B: true,
75+
C: null,
76+
})
77+
const body = await (await GET(createMockRequest('GET'))).json()
78+
expect(body.selfHosted).toEqual([
79+
{ cells: { Key: 'A', Value: '1' } },
80+
{ cells: { Key: 'B', Value: 'true' } },
81+
{ cells: { Key: 'C', Value: '' } },
82+
])
83+
})
84+
})
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { env } from '@/lib/core/config/env'
4+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
5+
6+
const logger = createLogger('ManagedAgentDefaultsAPI')
7+
8+
interface DefaultRow {
9+
cells: Record<string, string>
10+
}
11+
12+
/**
13+
* Read the JSON defaults for the Claude Managed Agents (self-hosted)
14+
* block's Session parameters table from the SERVER-ONLY env var
15+
* `MANAGED_AGENT_SELF_HOSTED_DEFAULTS`. Kept behind an API route (rather
16+
* than a `NEXT_PUBLIC_*` var) so seeded values never leak into the
17+
* client bundle at build time — deployers can safely put anything the
18+
* self-hosted agent sandbox reads without inadvertently shipping it to
19+
* the browser.
20+
*/
21+
function readSelfHostedDefaults(): DefaultRow[] {
22+
const raw = env.MANAGED_AGENT_SELF_HOSTED_DEFAULTS
23+
if (typeof raw !== 'string' || raw.trim().length === 0) return []
24+
try {
25+
const parsed = JSON.parse(raw)
26+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return []
27+
const rows: DefaultRow[] = []
28+
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
29+
const k = typeof key === 'string' ? key.trim() : ''
30+
if (!k) continue
31+
const v = value == null ? '' : typeof value === 'string' ? value : String(value)
32+
rows.push({ cells: { Key: k, Value: v } })
33+
}
34+
return rows
35+
} catch (error) {
36+
logger.warn('Failed to parse MANAGED_AGENT_SELF_HOSTED_DEFAULTS', { error })
37+
return []
38+
}
39+
}
40+
41+
/**
42+
* GET /api/managed-agent-defaults
43+
*
44+
* 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.
49+
*/
50+
export const GET = withRouteHandler(async (_request: NextRequest) => {
51+
return NextResponse.json({ selfHosted: readSelfHostedDefaults() }, { status: 200 })
52+
})

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/table/table.tsx

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ interface TableProps {
3131
* missing columns fall back to `""`. Existing values are never overwritten.
3232
*/
3333
defaultRows?: Array<{ cells: Record<string, string> }>
34+
/**
35+
* Optional async fetcher for seed rows — used when the block's default
36+
* rows come from a server-side source (e.g. deployer-configured env vars
37+
* read via an API route). Called once on first mount when the store is
38+
* empty. If the fetcher rejects or returns an empty array, falls back to
39+
* `defaultRows`, then to a single empty row. Same overwrite rules as
40+
* `defaultRows`: existing store values are never touched.
41+
*/
42+
fetchDefaultRows?: () => Promise<Array<{ cells: Record<string, string> }>>
3443
}
3544

3645
interface WorkflowTableRow {
@@ -221,6 +230,7 @@ export function Table({
221230
previewValue,
222231
disabled = false,
223232
defaultRows,
233+
fetchDefaultRows,
224234
}: TableProps) {
225235
const activeSearchTarget = useActiveSearchTarget()
226236
const params = useParams()
@@ -256,22 +266,47 @@ export function Table({
256266

257267
/**
258268
* Initialize the table when the component mounts and the store value is
259-
* missing/empty. If the caller supplied `defaultRows`, seed those rows
260-
* (any missing columns default to `""`); otherwise start with a single
261-
* empty row.
269+
* missing/empty. Precedence:
270+
* 1. `fetchDefaultRows` (async) — used when defaults live server-side
271+
* (e.g. deployer env-var read via an API route).
272+
* 2. `defaultRows` (sync) — static seeds declared on the block config.
273+
* 3. Single empty row — the pre-existing behavior.
274+
* Existing store values are never touched.
262275
*/
263276
useEffect(() => {
264-
if (!isPreview && !disabled && (!Array.isArray(storeValue) || storeValue.length === 0)) {
277+
if (isPreview || disabled) return
278+
if (Array.isArray(storeValue) && storeValue.length > 0) return
279+
let cancelled = false
280+
const seedWith = (rows: Array<{ cells: Record<string, string> }> | undefined) => {
281+
if (cancelled) return
265282
const seedRows: WorkflowTableRow[] =
266-
Array.isArray(defaultRows) && defaultRows.length > 0
267-
? defaultRows.map((row) => ({
283+
Array.isArray(rows) && rows.length > 0
284+
? rows.map((row) => ({
268285
id: generateId(),
269286
cells: { ...emptyCellsTemplate, ...(row.cells ?? {}) },
270287
}))
271288
: [{ id: generateId(), cells: { ...emptyCellsTemplate } }]
272289
setStoreValue(seedRows)
273290
}
274-
}, [isPreview, disabled, storeValue, setStoreValue, emptyCellsTemplate, defaultRows])
291+
if (fetchDefaultRows) {
292+
fetchDefaultRows()
293+
.then((rows) => seedWith(rows.length > 0 ? rows : defaultRows))
294+
.catch(() => seedWith(defaultRows))
295+
} else {
296+
seedWith(defaultRows)
297+
}
298+
return () => {
299+
cancelled = true
300+
}
301+
}, [
302+
isPreview,
303+
disabled,
304+
storeValue,
305+
setStoreValue,
306+
emptyCellsTemplate,
307+
defaultRows,
308+
fetchDefaultRows,
309+
])
275310

276311
// Ensure value is properly typed and initialized
277312
const rows = useMemo(() => {

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,7 @@ function SubBlockComponent({
754754
? (config.defaultValue as Array<{ cells: Record<string, string> }>)
755755
: undefined
756756
}
757+
fetchDefaultRows={config.fetchDefaultRows}
757758
/>
758759
)
759760

apps/sim/blocks/blocks/managed_agent_self_hosted.test.ts

Lines changed: 2 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
55

66
const { envState } = vi.hoisted(() => ({
77
envState: {
8-
NEXT_PUBLIC_MANAGED_AGENT_SELF_HOSTED_DEFAULTS: undefined as string | undefined,
98
NEXT_PUBLIC_MANAGED_AGENT_SELF_HOSTED_MEMORY_ENABLED: undefined as string | undefined,
109
},
1110
}))
@@ -20,75 +19,12 @@ vi.mock('@/lib/managed-agents/subblock-options', () => ({
2019
fetchManagedAgentAgentOptions: vi.fn(),
2120
fetchManagedAgentConnectionOptions: vi.fn(),
2221
fetchManagedAgentMemoryStoreOptions: vi.fn(),
22+
fetchManagedAgentSelfHostedDefaults: vi.fn(),
2323
fetchManagedAgentSelfHostedEnvironmentOptions: vi.fn(),
2424
fetchManagedAgentVaultOptions: vi.fn(),
2525
}))
2626

27-
import {
28-
isSelfHostedMemoryEnabled,
29-
readSessionMetadataDefaults,
30-
} from '@/blocks/blocks/managed_agent_self_hosted'
31-
32-
describe('readSessionMetadataDefaults', () => {
33-
beforeEach(() => {
34-
envState.NEXT_PUBLIC_MANAGED_AGENT_SELF_HOSTED_DEFAULTS = undefined
35-
})
36-
37-
it('returns [] when the env var is unset', () => {
38-
envState.NEXT_PUBLIC_MANAGED_AGENT_SELF_HOSTED_DEFAULTS = undefined
39-
expect(readSessionMetadataDefaults()).toEqual([])
40-
})
41-
42-
it('returns [] when the env var is empty / whitespace-only', () => {
43-
envState.NEXT_PUBLIC_MANAGED_AGENT_SELF_HOSTED_DEFAULTS = ''
44-
expect(readSessionMetadataDefaults()).toEqual([])
45-
envState.NEXT_PUBLIC_MANAGED_AGENT_SELF_HOSTED_DEFAULTS = ' '
46-
expect(readSessionMetadataDefaults()).toEqual([])
47-
})
48-
49-
it('returns [] on invalid JSON', () => {
50-
envState.NEXT_PUBLIC_MANAGED_AGENT_SELF_HOSTED_DEFAULTS = '{not json'
51-
expect(readSessionMetadataDefaults()).toEqual([])
52-
})
53-
54-
it('returns [] on a JSON array (must be an object of key/value pairs)', () => {
55-
envState.NEXT_PUBLIC_MANAGED_AGENT_SELF_HOSTED_DEFAULTS = '["a","b"]'
56-
expect(readSessionMetadataDefaults()).toEqual([])
57-
})
58-
59-
it('coerces a valid JSON object into `{cells: {Key, Value}}` rows', () => {
60-
envState.NEXT_PUBLIC_MANAGED_AGENT_SELF_HOSTED_DEFAULTS = JSON.stringify({
61-
FOO: 'bar',
62-
BAZ: 'qux',
63-
})
64-
expect(readSessionMetadataDefaults()).toEqual([
65-
{ cells: { Key: 'FOO', Value: 'bar' } },
66-
{ cells: { Key: 'BAZ', Value: 'qux' } },
67-
])
68-
})
69-
70-
it('drops entries with a blank key', () => {
71-
envState.NEXT_PUBLIC_MANAGED_AGENT_SELF_HOSTED_DEFAULTS = JSON.stringify({
72-
'': 'dropped',
73-
' ': 'also dropped',
74-
keep: 'yes',
75-
})
76-
expect(readSessionMetadataDefaults()).toEqual([{ cells: { Key: 'keep', Value: 'yes' } }])
77-
})
78-
79-
it('coerces non-string values to their string form', () => {
80-
envState.NEXT_PUBLIC_MANAGED_AGENT_SELF_HOSTED_DEFAULTS = JSON.stringify({
81-
A: 1,
82-
B: true,
83-
C: null,
84-
})
85-
expect(readSessionMetadataDefaults()).toEqual([
86-
{ cells: { Key: 'A', Value: '1' } },
87-
{ cells: { Key: 'B', Value: 'true' } },
88-
{ cells: { Key: 'C', Value: '' } },
89-
])
90-
})
91-
})
27+
import { isSelfHostedMemoryEnabled } from '@/blocks/blocks/managed_agent_self_hosted'
9228

9329
describe('isSelfHostedMemoryEnabled', () => {
9430
beforeEach(() => {

apps/sim/blocks/blocks/managed_agent_self_hosted.ts

Lines changed: 6 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
fetchManagedAgentAgentOptions,
55
fetchManagedAgentConnectionOptions,
66
fetchManagedAgentMemoryStoreOptions,
7+
fetchManagedAgentSelfHostedDefaults,
78
fetchManagedAgentSelfHostedEnvironmentOptions,
89
fetchManagedAgentVaultOptions,
910
} from '@/lib/managed-agents/subblock-options'
@@ -57,36 +58,6 @@ const memorySubBlocks: SubBlockConfig[] = isSelfHostedMemoryEnabled()
5758
]
5859
: []
5960

60-
/**
61-
* Read the JSON defaults for the self-hosted metadata table from
62-
* `NEXT_PUBLIC_MANAGED_AGENT_SELF_HOSTED_DEFAULTS`. Kept out of source
63-
* so deployers can seed the block with the keys their self-hosted
64-
* agent sandbox reads, without the values living in the repo.
65-
*
66-
* Expected shape: a JSON object `{"KEY": "VALUE", ...}`. Non-string
67-
* values are coerced to their string form. Anything invalid (missing,
68-
* unparseable, non-object) falls back to an empty seed — the block
69-
* still renders, users see a single blank row.
70-
*/
71-
export function readSessionMetadataDefaults(): Array<{ cells: Record<string, string> }> {
72-
const raw = env.NEXT_PUBLIC_MANAGED_AGENT_SELF_HOSTED_DEFAULTS
73-
if (typeof raw !== 'string' || raw.trim().length === 0) return []
74-
try {
75-
const parsed = JSON.parse(raw)
76-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return []
77-
const rows: Array<{ cells: Record<string, string> }> = []
78-
for (const [key, value] of Object.entries(parsed)) {
79-
const k = typeof key === 'string' ? key.trim() : ''
80-
if (!k) continue
81-
const v = value == null ? '' : typeof value === 'string' ? value : String(value)
82-
rows.push({ cells: { Key: k, Value: v } })
83-
}
84-
return rows
85-
} catch {
86-
return []
87-
}
88-
}
89-
9061
/**
9162
* Claude Managed Agents block — self-hosted variant.
9263
*
@@ -181,15 +152,16 @@ export const ManagedAgentSelfHostedBlock: BlockConfig = {
181152
// Session metadata forwarded to the self-hosted agent sandbox as
182153
// env vars. The set of supported keys is deployment-specific and
183154
// lives with the deployer, not in this repo. Seed rows come from
184-
// `NEXT_PUBLIC_MANAGED_AGENT_SELF_HOSTED_DEFAULTS` (see
185-
// `readSessionMetadataDefaults` above); leave empty to start with
186-
// a single blank row.
155+
// the server-only `MANAGED_AGENT_SELF_HOSTED_DEFAULTS` env var,
156+
// fetched via `/api/managed-agent-defaults` — the values never
157+
// enter the client bundle, so deployers can safely include
158+
// anything their sandbox reads.
187159
id: 'sessionParameters',
188160
title: 'Session parameters',
189161
type: 'table',
190162
required: false,
191163
columns: ['Key', 'Value'],
192-
defaultValue: readSessionMetadataDefaults(),
164+
fetchDefaultRows: fetchManagedAgentSelfHostedDefaults,
193165
description:
194166
'Key/value pairs forwarded to the self-hosted agent sandbox as environment variables. Supported keys depend on your deployment — consult your deployment docs. Value cells support <block.output> / <var.name> references.',
195167
},

apps/sim/blocks/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,13 @@ export interface SubBlockConfig {
285285
}
286286
})
287287
defaultValue?: string | number | boolean | Record<string, unknown> | Array<unknown>
288+
/**
289+
* Optional async fetcher for a `table` subblock's initial rows — used
290+
* when defaults come from a server-side source (e.g. deployer-configured
291+
* env vars) and must not be inlined into the client bundle. Called once
292+
* on first mount when the store is empty. See `Table.fetchDefaultRows`.
293+
*/
294+
fetchDefaultRows?: () => Promise<Array<{ cells: Record<string, string> }>>
288295
options?:
289296
| {
290297
label: string

0 commit comments

Comments
 (0)