Skip to content

Commit d5a91ff

Browse files
committed
address comments
1 parent 8cb535d commit d5a91ff

12 files changed

Lines changed: 522 additions & 177 deletions

File tree

apps/sim/app/api/function/execute/route.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2089,6 +2089,50 @@ describe('Function Execute API Route', () => {
20892089
expect((await response.json()).__resolvedSecretNames).toEqual([])
20902090
})
20912091

2092+
it('conservatively reports only compiled secrets when bounded output classification is exceeded', async () => {
2093+
const result = Array.from({ length: 100_001 }, () => 'ordinary')
2094+
mockExecuteInIsolatedVM.mockResolvedValueOnce({ result, stdout: '' })
2095+
2096+
const response = await POST(
2097+
createMockRequest(
2098+
'POST',
2099+
{
2100+
code: 'const key = {{API_KEY}}; return params.items',
2101+
params: { items: result },
2102+
envVars: { API_KEY: 'secret-value', UNUSED: 'x' },
2103+
},
2104+
{ 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1' }
2105+
)
2106+
)
2107+
const data = await response.json()
2108+
2109+
expect(response.status).toBe(200)
2110+
expect(response.headers.get('x-sim-private-tool-metadata')).toBe('resolved-secret-names-v1')
2111+
expect(data.output.result).toHaveLength(100_001)
2112+
expect(data.output.result[0]).toBe('ordinary')
2113+
expect(data.__resolvedSecretNames).toEqual(['API_KEY'])
2114+
})
2115+
2116+
it('conservatively reports a compiled secret whose value exceeds matcher capacity', async () => {
2117+
mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 'ordinary', stdout: '' })
2118+
2119+
const response = await POST(
2120+
createMockRequest(
2121+
'POST',
2122+
{
2123+
code: 'const key = {{OVERSIZED_SECRET}}; return "ordinary"',
2124+
envVars: { OVERSIZED_SECRET: 's'.repeat(64 * 1024 + 1), UNUSED: 'x' },
2125+
},
2126+
{ 'x-sim-request-private-tool-metadata': 'resolved-secret-names-v1' }
2127+
)
2128+
)
2129+
const data = await response.json()
2130+
2131+
expect(response.status).toBe(200)
2132+
expect(data.output.result).toBe('ordinary')
2133+
expect(data.__resolvedSecretNames).toEqual(['OVERSIZED_SECRET'])
2134+
})
2135+
20922136
it('tracks only compiled names when configured secrets share the same value', async () => {
20932137
mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 'true', stdout: '' })
20942138
const oneResponse = await POST(

apps/sim/app/api/function/execute/route.ts

Lines changed: 30 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,6 @@ const TAG_PATTERN = createReferencePattern()
113113
const E2B_JS_WRAPPER_LINES = 3
114114
const E2B_PYTHON_WRAPPER_LINES = 1
115115
const MAX_SANDBOX_OUTPUT_FILES = 20
116-
const MAX_PRIVATE_RESOLVED_SECRET_NAMES = 10_000
117-
const MAX_PRIVATE_RESOLVED_SECRET_NAMES_BYTES = 1024 * 1024
118116
const MAX_PRIVATE_FILE_SECRET_MATCH_EVENTS = 1_000_000
119117
const SANDBOX_RUNTIME_PAYLOAD_PATH_ENV = '__SIM_RUNTIME_PAYLOAD_PATH'
120118

@@ -984,7 +982,6 @@ interface FunctionRouteExecutionContext {
984982
resolvedSecretNames: Set<string>
985983
includePrivateResolvedSecretNames: boolean
986984
privateResolvedSecretNamesMetadataType?: ResolvedSecretNamesMetadataType
987-
outputProvenanceComplete: boolean
988985
outputSecretMatcher?: ResolvedSecretMatcher
989986
outputSecretNamesByScanLiteral: Map<string, string[]>
990987
outputSecretPlaintextsByName: Map<string, string>
@@ -1193,7 +1190,10 @@ function activateOutputSecretProvenance(
11931190
body: unknown,
11941191
context: FunctionRouteExecutionContext
11951192
): void {
1196-
if (!context.outputSecretMatcher) return
1193+
if (!context.outputSecretMatcher) {
1194+
activateCompiledSecretProvenance(context)
1195+
return
1196+
}
11971197

11981198
const matchedPlaintexts = new Set<string>()
11991199
const projection = projectResolvedSecretContent(
@@ -1205,7 +1205,7 @@ function activateOutputSecretProvenance(
12051205
}
12061206
)
12071207
if (!projection.safe) {
1208-
context.outputProvenanceComplete = false
1208+
activateCompiledSecretProvenance(context)
12091209
return
12101210
}
12111211
for (const plaintext of matchedPlaintexts) {
@@ -1215,6 +1215,17 @@ function activateOutputSecretProvenance(
12151215
}
12161216
}
12171217

1218+
/**
1219+
* Conservatively activates only secrets whose placeholders were compiled for this invocation.
1220+
* This fallback is used when the bounded output classifier cannot inspect a result; it never
1221+
* considers configured-but-unused environment values and never mutates the functional result.
1222+
*/
1223+
function activateCompiledSecretProvenance(context: FunctionRouteExecutionContext): void {
1224+
for (const name of context.outputSecretPlaintextsByName.keys()) {
1225+
context.resolvedSecretNames.add(name)
1226+
}
1227+
}
1228+
12181229
/**
12191230
* True when this execution compiled a secret placeholder or received a mounted file with verified
12201231
* secret provenance. Ordinary mounts without a provenance envelope are user data, not evidence that
@@ -1267,7 +1278,6 @@ async function getOutputFileSecretProvenance(
12671278
MAX_PRIVATE_FILE_SECRET_MATCH_EVENTS
12681279
)
12691280
} catch {
1270-
context.outputProvenanceComplete = false
12711281
return { status: 'unknown' }
12721282
}
12731283

@@ -1292,17 +1302,8 @@ async function getOutputFileSecretProvenance(
12921302
}
12931303
}
12941304

1295-
function getPrivateResolvedSecretNames(context: FunctionRouteExecutionContext): string[] | null {
1296-
if (!context.outputProvenanceComplete) return null
1297-
if (context.resolvedSecretNames.size > MAX_PRIVATE_RESOLVED_SECRET_NAMES) return null
1298-
1299-
const names = Array.from(context.resolvedSecretNames).sort()
1300-
let bytes = 0
1301-
for (const name of names) {
1302-
bytes += Buffer.byteLength(name, 'utf8')
1303-
if (bytes > MAX_PRIVATE_RESOLVED_SECRET_NAMES_BYTES) return null
1304-
}
1305-
return names
1305+
function getPrivateResolvedSecretNames(context: FunctionRouteExecutionContext): string[] {
1306+
return Array.from(context.resolvedSecretNames).sort()
13061307
}
13071308

13081309
async function appendResolvedSecretNames(
@@ -1326,21 +1327,17 @@ async function appendPrivateResolvedSecretNames(
13261327
): Promise<NextResponse> {
13271328
if (!names || !metadataType) return response
13281329

1329-
try {
1330-
const body = (await response.clone().json()) as Record<string, unknown>
1331-
const headers = new Headers(response.headers)
1332-
headers.delete('content-length')
1333-
headers.set(PRIVATE_TOOL_METADATA_RESPONSE_HEADER, metadataType)
1334-
return NextResponse.json(
1335-
{
1336-
...body,
1337-
[RESOLVED_SECRET_NAMES_FIELD]: names,
1338-
},
1339-
{ status: response.status, statusText: response.statusText, headers }
1340-
)
1341-
} catch {
1342-
return response
1343-
}
1330+
const body = (await response.json()) as Record<string, unknown>
1331+
const headers = new Headers(response.headers)
1332+
headers.delete('content-length')
1333+
headers.set(PRIVATE_TOOL_METADATA_RESPONSE_HEADER, metadataType)
1334+
return NextResponse.json(
1335+
{
1336+
...body,
1337+
[RESOLVED_SECRET_NAMES_FIELD]: names,
1338+
},
1339+
{ status: response.status, statusText: response.statusText, headers }
1340+
)
13441341
}
13451342

13461343
/**
@@ -2025,7 +2022,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
20252022
resolvedSecretNames: new Set<string>(),
20262023
includePrivateResolvedSecretNames,
20272024
privateResolvedSecretNamesMetadataType,
2028-
outputProvenanceComplete: true,
20292025
outputSecretNamesByScanLiteral: new Map(),
20302026
outputSecretPlaintextsByName: new Map(),
20312027
mountedFileSecretProvenanceScanner,
@@ -2078,7 +2074,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
20782074
}))
20792075
)
20802076
} catch {
2081-
routeContext.outputProvenanceComplete = false
2077+
activateCompiledSecretProvenance(routeContext)
20822078
}
20832079
}
20842080
resolvedCode = compilation.code

apps/sim/app/api/mcp/tools/execute/route.test.ts

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,11 @@ const {
99
mockDiscoverServerTools,
1010
mockExecuteTool,
1111
mockGetExecutionTimeout,
12-
mockReadResponseToBufferWithLimit,
1312
} = vi.hoisted(() => ({
1413
mockCapExecutionTimeoutMs: vi.fn((_policy: number, requested?: number) => requested ?? 0),
1514
mockDiscoverServerTools: vi.fn(),
1615
mockExecuteTool: vi.fn(),
1716
mockGetExecutionTimeout: vi.fn(() => 0),
18-
mockReadResponseToBufferWithLimit: vi.fn(),
19-
}))
20-
21-
vi.mock('@/lib/core/utils/stream-limits', () => ({
22-
readResponseToBufferWithLimit: mockReadResponseToBufferWithLimit,
2317
}))
2418

2519
vi.mock('@/lib/mcp/middleware', () => ({
@@ -102,9 +96,6 @@ describe('MCP tool execution private secret provenance', () => {
10296
vi.clearAllMocks()
10397
mockDiscoverServerTools.mockResolvedValue([{ name: 'example_tool', inputSchema: {} }])
10498
mockExecuteTool.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] })
105-
mockReadResponseToBufferWithLimit.mockImplementation(async (response: Response) =>
106-
Buffer.from(await response.arrayBuffer())
107-
)
10899
})
109100

110101
it('returns provenance activated by this MCP transport call', async () => {
@@ -160,10 +151,10 @@ describe('MCP tool execution private secret provenance', () => {
160151
expect(mockExecuteTool.mock.calls[0]?.[5]).toBeUndefined()
161152
})
162153

163-
it('preserves the functional response when private provenance cannot be attached', async () => {
164-
mockReadResponseToBufferWithLimit.mockRejectedValueOnce(new Error('Response exceeds limit'))
154+
it('attaches private provenance without imposing a second functional response limit', async () => {
155+
const largeText = 'x'.repeat(10 * 1024 * 1024 + 1)
165156
mockExecuteTool.mockResolvedValueOnce({
166-
content: [{ type: 'text', text: 'unchanged' }],
157+
content: [{ type: 'text', text: largeText }],
167158
})
168159
const request = createRequest({
169160
'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1',
@@ -174,8 +165,15 @@ describe('MCP tool execution private secret provenance', () => {
174165

175166
expect(response.status).toBe(200)
176167
expect(response.ok).toBe(true)
177-
expect(response.headers.has('x-sim-private-tool-metadata')).toBe(false)
178-
expect(body).not.toHaveProperty('__resolvedSecretTraceProvenance')
168+
expect(response.headers.get('x-sim-private-tool-metadata')).toBe(
169+
'resolved-secret-provenance-v1'
170+
)
171+
expect(body.__resolvedSecretTraceProvenance).toEqual({
172+
version: 1,
173+
complete: true,
174+
entries: [],
175+
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
176+
})
179177
expect(body).toMatchObject({
180178
success: true,
181179
data: {
@@ -185,7 +183,7 @@ describe('MCP tool execution private secret provenance', () => {
185183
})
186184
expect(
187185
(body.data as { output: { content: Array<{ text?: unknown }> } }).output.content[0]?.text
188-
).toBe('unchanged')
186+
).toBe(largeText)
189187
})
190188

191189
it('uses the remaining workflow deadline for trusted internal tool calls', async () => {

apps/sim/app/api/mcp/tools/execute/route.ts

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
} from '@/lib/billing/core/billing-attribution'
1212
import { capExecutionTimeoutMs, getExecutionTimeout } from '@/lib/core/execution-limits'
1313
import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types'
14-
import { readResponseToBufferWithLimit } from '@/lib/core/utils/stream-limits'
1514
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1615
import { SIM_VIA_HEADER } from '@/lib/execution/call-chain'
1716
import { parseRemainingExecutionDeadlineMs } from '@/lib/execution/execution-deadline-header'
@@ -45,7 +44,6 @@ import {
4544
} from '@/executor/utils/resolved-secret-trace-registry'
4645

4746
const logger = createLogger('McpToolExecutionAPI')
48-
const MAX_PRIVATE_MCP_RESPONSE_BYTES = 10 * 1024 * 1024
4947

5048
export const dynamic = 'force-dynamic'
5149

@@ -72,21 +70,11 @@ async function attachPrivateProvenance(
7270
response: NextResponse,
7371
provenance: ResolvedSecretTraceProvenanceAccumulator
7472
): Promise<NextResponse> {
75-
let payload: Record<string, unknown>
76-
try {
77-
const body = await readResponseToBufferWithLimit(response.clone(), {
78-
maxBytes: MAX_PRIVATE_MCP_RESPONSE_BYTES,
79-
label: 'MCP private metadata response',
80-
allowNoBodyFallback: true,
81-
})
82-
const parsed: unknown = JSON.parse(body.toString('utf8'))
83-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
84-
throw new Error('MCP response is not a JSON object')
85-
}
86-
payload = parsed as Record<string, unknown>
87-
} catch {
88-
return response
73+
const parsed: unknown = await response.json()
74+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
75+
throw new Error('MCP response is not a JSON object')
8976
}
77+
const payload = parsed as Record<string, unknown>
9078

9179
const headers = new Headers(response.headers)
9280
headers.delete('content-length')

apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.test.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { knowledgeBase, workflow, workflowBlocks, workflowDeploymentVersion } from '@sim/db/schema'
4+
import {
5+
document,
6+
knowledgeBase,
7+
workflow,
8+
workflowBlocks,
9+
workflowDeploymentVersion,
10+
} from '@sim/db/schema'
511
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
612
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
713

@@ -341,6 +347,75 @@ describe('cleanup-failed', () => {
341347
expect(deletes()[0].table).toBe(knowledgeBase)
342348
})
343349

350+
it('keeps a failed copied knowledge base when it contains a non-fork document', async () => {
351+
queueTableRows(knowledgeBase, [{ id: 'failed-kb' }])
352+
353+
const cleaned = await clearFailedForkResourceReferences({
354+
childWorkspaceId: 'child-ws',
355+
failures: [{ kind: 'knowledge-base', childId: 'failed-kb', documentChildIds: [] }],
356+
requestId: 'test',
357+
})
358+
359+
expect(cleaned).toEqual({ cleared: 0, clearingFailed: false })
360+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
361+
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
362+
expect(mockInvalidateDeployedStateCache).not.toHaveBeenCalled()
363+
})
364+
365+
it('clears only failed fork-document references when a user document keeps the KB alive', async () => {
366+
queueTableRows(knowledgeBase, [{ id: 'failed-kb' }])
367+
queueTableRows(workflow, [{ id: 'wf-1' }])
368+
queueTableRows(workflowBlocks, [
369+
{
370+
...draftBlockRow('failed-kb'),
371+
subBlocks: {
372+
...draftBlockRow('failed-kb').subBlocks,
373+
documentId: {
374+
id: 'documentId',
375+
type: 'document-selector',
376+
value: 'fork_document_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
377+
},
378+
},
379+
},
380+
])
381+
382+
const cleaned = await clearFailedForkResourceReferences({
383+
childWorkspaceId: 'child-ws',
384+
failures: [
385+
{
386+
kind: 'knowledge-base',
387+
childId: 'failed-kb',
388+
documentChildIds: ['fork_document_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'],
389+
},
390+
],
391+
requestId: 'test',
392+
})
393+
394+
expect(cleaned).toEqual({ cleared: 1, clearingFailed: false })
395+
const cleared = updates()[0].values.subBlocks as Record<string, { value: unknown }>
396+
expect(cleared.knowledgeBaseId.value).toBe('failed-kb')
397+
expect(cleared.documentId.value).toBe('')
398+
expect(deletes().map(({ table }) => table)).toEqual([document])
399+
})
400+
401+
it('guards the final KB delete against a non-fork document inserted during cleanup', async () => {
402+
queueTableRows(workflow, [])
403+
404+
await clearFailedForkResourceReferences({
405+
childWorkspaceId: 'child-ws',
406+
failures: [{ kind: 'knowledge-base', childId: 'failed-kb', documentChildIds: [] }],
407+
requestId: 'test',
408+
})
409+
410+
const deletePredicate = dbChainMockFns.where.mock.calls.at(-1)?.[0]
411+
expect(deletePredicate).toEqual(
412+
expect.objectContaining({
413+
type: 'and',
414+
conditions: expect.arrayContaining([expect.objectContaining({ type: 'notExists' })]),
415+
})
416+
)
417+
})
418+
344419
it('sweeps a deployed target version even when no draft referenced the failed id', async () => {
345420
// Draft is clean (other-kb), but a deployed target version still points at the dropped
346421
// placeholder - the deployed-target scope (not draft divergence) catches it.

0 commit comments

Comments
 (0)