Skip to content

Commit 26012eb

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
Merge remote-tracking branch 'origin/staging' into feat/unified-server-selector-execution
2 parents 96505eb + faec125 commit 26012eb

36 files changed

Lines changed: 1340 additions & 693 deletions

apps/docs/content/docs/integrations/elasticsearch.mdx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ Retrieve index information including settings, mappings, and aliases.
306306

307307
| Parameter | Type | Description |
308308
| --------- | ---- | ----------- |
309-
| `index` | json | Index information including aliases, mappings, and settings |
309+
| `indices` | json | Matched indices keyed by index name, each with its aliases, mappings, and settings |
310310

311311
### Elasticsearch Cluster Health
312312

@@ -324,7 +324,7 @@ Get the health status of the Elasticsearch cluster.
324324
| `username` | string | No | Username for basic auth |
325325
| `password` | string | No | Password for basic auth |
326326
| `waitForStatus` | string | No | Wait until cluster reaches this status: green, yellow, or red |
327-
| `timeout` | string | No | Timeout for the wait operation \(e.g., 30s, 1m\) |
327+
| `clusterTimeout` | string | No | How long Elasticsearch waits for the cluster to reach the requested status, as an Elasticsearch time value \(e.g., 30s, 1m\). Not named "timeout": that name is reserved by the tool transport as a client-side abort deadline in milliseconds. |
328328

329329
#### Output
330330

@@ -377,12 +377,13 @@ List all indices in the Elasticsearch cluster with their health, status, and sta
377377
| `apiKey` | string | No | Elasticsearch API key |
378378
| `username` | string | No | Username for basic auth |
379379
| `password` | string | No | Password for basic auth |
380+
| `includeSystemIndices` | boolean | No | Include Elasticsearch system indices \(names starting with "."\). Omitted by default. |
380381

381382
#### Output
382383

383384
| Parameter | Type | Description |
384385
| --------- | ---- | ----------- |
385386
| `message` | string | Summary message about the indices |
386-
| `indices` | json | Array of index information objects |
387+
| `indices` | json | Array of index information objects \(index, health, status, docsCount, storeSize, primaryShards, replicaShards\). System indices are omitted unless includeSystemIndices is set. |
387388

388389

apps/sim/app/(landing)/integrations/data/seo-content.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,17 @@ export const INTEGRATION_SEO: Record<string, IntegrationSeoContent> = {
5858
'slack workflow automation',
5959
'slack integration',
6060
],
61-
h1: 'Slack Integrations for Workflow Automation',
61+
h1: 'Slack Workflow Automation with Sim',
6262
tagline:
6363
'Build Slack workflow automation in Sim. Send, update, delete, and read messages; manage channels, users, canvases, and modals; and trigger AI agents from mentions, messages, and reactions in real time.',
6464
overview:
65-
'Use Sim as your Slack integration for team communication and operations. Build Slack automation that routes requests, posts alerts, summarises threads, updates tickets, and keeps work moving. Sim supports messages, reactions, canvases, views, channel and user lookups, file downloads, and real-time Slack workflows in one workspace.',
65+
'Sim automates Slack workflows that depend on conversation context, including message routing, alerts, thread summaries, ticket updates, and incident response. Slack messages and events start agent workflows that interpret what was said and choose the next action in Slack or a connected tool, so routine coordination and time-sensitive operations keep moving without anyone relaying details by hand.',
6666
triggersIntro:
67-
'Connect the Slack Webhook trigger to Sim and run Slack workflow automation the moment a mention, message, or reaction happens, no polling, no delay.',
67+
'Sim supports one real-time Slack trigger. Select the Slack events you care about, such as mentions, messages, and reactions, and Sim starts the connected workflow the moment one arrives instead of waiting for a scheduled check. A monitoring alert posted in Slack can open an incident-response workflow, and a ticketing update posted in Slack can be summarised and passed to another connected tool.',
6868
templatesIntro:
69-
'Ready-to-use Slack automation templates for Q&A bots, sales alerts, incident response, standups, digests, and CRM updates. Click any template to launch a workflow faster.',
69+
'Pre-built agent templates turn common Slack workflows into editable starting points: routing templates classify messages and send them to the right channel or owner, summarisation templates condense long threads into updates that preserve decisions and action items, and ticket sync and incident response templates update connected records and coordinate follow-up. Every template is editable, so you can adapt its channels, routing rules, data sources, and approval requirements.',
70+
toolsSubtitleSuffix:
71+
' across messaging, channels, threads, users, reactions and files, and canvases and views. Combine multiple Slack actions in one workflow to summarise a message, route it, update a ticket, and post the ticket update back in Slack',
7072
},
7173
airtable: {
7274
title: 'Airtable Automation with Sim',

apps/sim/blocks/blocks/elasticsearch.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -483,15 +483,32 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
483483
condition: { field: 'operation', value: 'elasticsearch_cluster_health' },
484484
},
485485

486-
// Cluster health timeout
486+
// Cluster health timeout. The subBlock id stays `timeout` so saved workflow
487+
// state keeps resolving; `tools.config.params` remaps it to `clusterTimeout`
488+
// and clears the transport's reserved `timeout` key.
487489
{
488490
id: 'timeout',
489-
title: 'Timeout (seconds)',
491+
title: 'Timeout',
490492
type: 'short-input',
491-
placeholder: '30',
493+
placeholder: '30s',
494+
mode: 'advanced',
492495
condition: { field: 'operation', value: 'elasticsearch_cluster_health' },
493496
},
494497

498+
// Include system indices
499+
{
500+
id: 'includeSystemIndices',
501+
title: 'Include System Indices',
502+
type: 'dropdown',
503+
options: [
504+
{ label: 'No', id: '' },
505+
{ label: 'Yes', id: 'true' },
506+
],
507+
value: () => '',
508+
mode: 'advanced',
509+
condition: { field: 'operation', value: 'elasticsearch_list_indices' },
510+
},
511+
495512
// Retry on conflict
496513
{
497514
id: 'retryOnConflict',
@@ -528,9 +545,15 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
528545
if (params.size) result.size = Number(params.size)
529546
if (params.from) result.from = Number(params.from)
530547
if (params.retryOnConflict) result.retryOnConflict = Number(params.retryOnConflict)
531-
if (params.timeout && typeof params.timeout === 'string') {
532-
result.timeout = params.timeout.endsWith('s') ? params.timeout : `${params.timeout}s`
548+
549+
if (params.includeSystemIndices === 'true') result.includeSystemIndices = true
550+
551+
const rawTimeout = typeof params.timeout === 'string' ? params.timeout.trim() : ''
552+
if (rawTimeout) {
553+
result.clusterTimeout = /^\d+$/.test(rawTimeout) ? `${rawTimeout}s` : rawTimeout
533554
}
555+
result.timeout = undefined
556+
534557
return result
535558
},
536559
},
@@ -559,7 +582,14 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
559582
mappings: { type: 'string', description: 'Index mappings as JSON' },
560583
refresh: { type: 'string', description: 'Refresh policy' },
561584
waitForStatus: { type: 'string', description: 'Wait for cluster status' },
562-
timeout: { type: 'string', description: 'Timeout for wait operations' },
585+
timeout: {
586+
type: 'string',
587+
description: 'How long Elasticsearch waits for the cluster to reach the requested status',
588+
},
589+
includeSystemIndices: {
590+
type: 'string',
591+
description: 'Include Elasticsearch system indices when listing',
592+
},
563593
retryOnConflict: { type: 'number', description: 'Retry attempts on conflict' },
564594
},
565595

@@ -581,8 +611,14 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
581611
items: { type: 'json', description: 'Bulk operation results' },
582612
// Count outputs
583613
count: { type: 'number', description: 'Document count' },
614+
_shards: {
615+
type: 'json',
616+
description: 'Shard statistics (total, successful, skipped, failed)',
617+
},
584618
// Index outputs
585619
acknowledged: { type: 'boolean', description: 'Whether operation was acknowledged' },
620+
// List indices outputs
621+
message: { type: 'string', description: 'Summary message about the indices listed' },
586622
// Cluster outputs
587623
cluster_name: { type: 'string', description: 'Cluster name' },
588624
status: { type: 'string', description: 'Cluster health status' },

apps/sim/executor/variables/resolver.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -694,9 +694,10 @@ export class VariableResolver {
694694
return null
695695
}
696696

697-
// Reuse an existing marker for the same file so referencing one path twice
698-
// mounts it once, rather than transferring a second copy under a
699-
// collision-suffixed name and spending the mount budget twice.
697+
// Reuse the marker already standing for this file so a path referenced twice
698+
// costs one context variable rather than two. What keeps it to one mount is
699+
// `planUserFileMounts`, which collapses by storage key across every source —
700+
// this only keeps the duplicate out of the request body.
700701
const existing = Object.entries(contextVarAccumulator).find(
701702
([, value]) => isSandboxFileMountRef(value) && value.file.key === file.key
702703
)

apps/sim/lib/charts/spec.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ describe('shapeTableRows', () => {
9595
})
9696

9797
const XSS_FORMATTER = '<img src=x onerror="alert(1)">'
98+
const XSS_LINK = 'javascript:alert(document.domain)'
9899

99100
describe('parseChartSpec option confinement', () => {
100101
it('forces the tooltip off the innerHTML path, keeping the formatter template', () => {
@@ -175,6 +176,23 @@ describe('parseChartSpec option confinement', () => {
175176
expect(media[0].option.toolbox).toBeUndefined()
176177
})
177178

179+
it('drops every navigation sink — title link/sublink and treemap/sunburst item links', () => {
180+
const option = parse({
181+
schema_version: 1,
182+
option: {
183+
title: { text: 'click me', link: XSS_LINK, sublink: XSS_LINK, target: 'self' },
184+
series: [
185+
{ type: 'treemap', data: [{ name: 'a', value: 1, link: XSS_LINK }] },
186+
{ type: 'sunburst', data: [{ name: 'b', value: 1, link: XSS_LINK }] },
187+
],
188+
baseOption: { title: { link: XSS_LINK } },
189+
media: [{ query: { minWidth: 100 }, option: { title: { link: XSS_LINK } } }],
190+
},
191+
})
192+
expect(JSON.stringify(option)).not.toContain('javascript:')
193+
expect(option.title).toEqual({ text: 'click me', target: 'self' })
194+
})
195+
178196
it('adds no tooltip to a document that declares none', () => {
179197
const option = parse({ schema_version: 1, option: { series: [{ type: 'bar', data: [1] }] } })
180198
expect('tooltip' in option).toBe(false)
@@ -191,7 +209,7 @@ describe('parseChartSpec option confinement', () => {
191209
})
192210

193211
it('leaves dataset rows alone — they hold data, not components', () => {
194-
const rows = [{ tooltip: 'ok', toolbox: 'ok' }]
212+
const rows = [{ tooltip: 'ok', toolbox: 'ok', link: 'ok' }]
195213
const option = parse({ schema_version: 1, option: { dataset: { source: rows } } })
196214
expect((option.dataset as Record<string, unknown>).source).toEqual(rows)
197215
})
@@ -268,6 +286,24 @@ describe('chart option confinement against echarts', () => {
268286
)
269287
expect(model.getComponent('toolbox')).toBeUndefined()
270288
})
289+
290+
it('leaves the title component no link to hand to windowOpen', () => {
291+
const model = renderModel(
292+
parse({
293+
schema_version: 1,
294+
option: {
295+
xAxis: {},
296+
yAxis: {},
297+
series: [{ type: 'bar', data: [1] }],
298+
title: { text: 'click me', link: XSS_LINK, sublink: XSS_LINK },
299+
},
300+
})
301+
)
302+
const title = model.getComponent('title')
303+
expect(title?.get('text')).toBe('click me')
304+
expect(title?.get('link')).toBeUndefined()
305+
expect(title?.get('sublink')).toBeUndefined()
306+
})
271307
})
272308

273309
describe('parseChartSpec table-shaping validation', () => {

apps/sim/lib/charts/spec.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ export interface ChartSpec {
5252
/** ECharts' tooltip render mode that draws into the chart canvas instead of the DOM. */
5353
const CANVAS_TOOLTIP_RENDER_MODE = 'richText'
5454

55+
/** Option keys stripped at every level: the `toolbox` DOM sink and the `link`/`sublink` navigation sinks. */
56+
const DROPPED_KEYS = ['toolbox', 'link', 'sublink'] as const
57+
5558
/**
5659
* Closes the paths by which an ECharts option reaches the DOM, so a `.chart`
5760
* document cannot inject markup into the page that renders it. A document is
@@ -63,7 +66,14 @@ const CANVAS_TOOLTIP_RENDER_MODE = 'richText'
6366
* string `formatter` is used as that content's template verbatim — only the
6467
* values substituted into it are escaped. A `toolbox` assigns `dataView.lang`
6568
* entries to `innerHTML` and fills a `saveAsImage` popup with `document.write`.
66-
* Forcing the render mode and dropping the toolbox leaves the document no DOM
69+
* Forcing the render mode and dropping the toolbox leaves it no DOM sink.
70+
*
71+
* ECharts also navigates: `title.link`, `title.sublink`, and a `link` on a
72+
* treemap or sunburst data item each reach `windowOpen`, which assigns the URL
73+
* to `location.href` — so a `javascript:` URL runs on this origin on a single
74+
* click. A chart has no reason to navigate its viewer, so the keys are dropped
75+
* everywhere rather than scheme-checked, which would still leave an open
76+
* redirect on an authenticated origin. Between them the document is left no
6777
* sink at all, which holds whatever any individual option value contains.
6878
*
6979
* The walk is deep because `tooltip` is not only a top-level component:
@@ -79,8 +89,9 @@ function confineOptionToCanvas(node: unknown): void {
7989
}
8090
if (node === null || typeof node !== 'object') return
8191
const record = node as Record<string, unknown>
82-
// biome-ignore lint/performance/noDelete: the key must be absent, not undefined-valued
83-
if ('toolbox' in record) delete record.toolbox
92+
for (const key of DROPPED_KEYS) {
93+
if (key in record) delete record[key]
94+
}
8495
for (const key of Object.keys(record)) {
8596
if (key === 'dataset') continue
8697
const value = record[key]

apps/sim/lib/execution/remote-sandbox/conformance.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,6 +868,59 @@ describe.each(PROVIDERS)('sandbox conformance [%s]', (provider) => {
868868
).rejects.toThrow(/over the 20-file export limit/)
869869
})
870870

871+
it('spends one file ceiling across declared and harvested outputs', async () => {
872+
// The limit is what an execution exports, not what one directory holds, so a
873+
// request that both declares and harvests cannot take 20 of each.
874+
stubCodeRun(provider, `__SIM_RESULT__=${JSON.stringify('done')}`)
875+
stubOutputFileSizes(provider, 1, 1)
876+
stubOutputDirListing(
877+
Array.from({ length: MAX_SANDBOX_OUTPUT_FILES - 1 }, (_, index) => ({
878+
path: `/tmp/sim/outputs/file-${index}.txt`,
879+
size: 1,
880+
}))
881+
)
882+
883+
await expect(
884+
executeInSandbox({
885+
code: 'x',
886+
language: CodeLanguage.Python,
887+
timeoutMs: 1000,
888+
outputSandboxPaths: ['/out/first.txt', '/out/second.txt'],
889+
outputSandboxDir: '/tmp/sim/outputs',
890+
})
891+
).rejects.toThrow(/produced 21 files .* over the 20-file export limit/)
892+
})
893+
894+
it('does not charge a declared path inside the harvest directory to the ceiling twice', async () => {
895+
// The directory holds exactly the limit and the request names one of those
896+
// files. Charging it on both sides would refuse a run exporting 20 files.
897+
stubCodeRun(provider, `__SIM_RESULT__=${JSON.stringify('done')}`)
898+
// One inspection for the declared path, then one per file actually read.
899+
stubOutputFileSizes(provider, ...Array.from({ length: MAX_SANDBOX_OUTPUT_FILES + 1 }, () => 1))
900+
stubOutputDirListing(
901+
Array.from({ length: MAX_SANDBOX_OUTPUT_FILES }, (_, index) => ({
902+
path: `/tmp/sim/outputs/file-${index}.txt`,
903+
size: 1,
904+
}))
905+
)
906+
for (let index = 0; index < MAX_SANDBOX_OUTPUT_FILES; index += 1) {
907+
stubOutputFileRead(provider, 'x')
908+
}
909+
910+
const result = await executeInSandbox({
911+
code: 'x',
912+
language: CodeLanguage.Python,
913+
timeoutMs: 1000,
914+
outputSandboxPath: '/tmp/sim/outputs/file-0.txt',
915+
outputSandboxDir: '/tmp/sim/outputs',
916+
})
917+
918+
// Exported once as a declared path, rather than a second time as a harvest.
919+
expect(Object.keys(result.exportedFiles ?? {})).toEqual(['/tmp/sim/outputs/file-0.txt'])
920+
expect(result.collectedFiles).toHaveLength(MAX_SANDBOX_OUTPUT_FILES - 1)
921+
expect(result.collectedFiles?.map((file) => file.relativePath)).not.toContain('file-0.txt')
922+
})
923+
871924
it('does not list the output directory when no harvest was requested', async () => {
872925
stubCodeRun(provider, `__SIM_RESULT__=${JSON.stringify('done')}`)
873926

apps/sim/lib/execution/remote-sandbox/index.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -554,10 +554,16 @@ function requestedOutputSandboxPaths(req: {
554554
* too many files, or nesting past what the listing reaches — before a single
555555
* byte is read. Sorted so a multi-file result is stable run to run rather than
556556
* inheriting whatever order the provider happened to return.
557+
*
558+
* `declaredPaths` are the files the request already named. One sitting inside the
559+
* directory is dropped rather than harvested a second time, and the rest count
560+
* toward the ceiling: the limit is what one execution exports, not what one
561+
* directory holds, so declaring and harvesting cannot spend it twice.
557562
*/
558563
async function listOutputDirectoryFiles(
559564
sandbox: SandboxHandle,
560565
outputSandboxDir: string,
566+
declaredPaths: ReadonlySet<string>,
561567
signal: AbortSignal
562568
): Promise<SandboxDirectoryEntry[]> {
563569
let listed: SandboxDirectoryEntry[]
@@ -593,9 +599,10 @@ async function listOutputDirectoryFiles(
593599
)
594600
}
595601

596-
const files = entries.filter((entry) => entry.kind === 'file')
597-
if (files.length > MAX_SANDBOX_OUTPUT_FILES) {
598-
throw new SandboxOutputFileCountError(files.length, outputSandboxDir)
602+
const files = entries.filter((entry) => entry.kind === 'file' && !declaredPaths.has(entry.path))
603+
const exported = declaredPaths.size + files.length
604+
if (exported > MAX_SANDBOX_OUTPUT_FILES) {
605+
throw new SandboxOutputFileCountError(exported, outputSandboxDir)
599606
}
600607
return files.sort((a, b) => a.path.localeCompare(b.path))
601608
}
@@ -640,16 +647,14 @@ async function collectExportedFiles(
640647
}
641648

642649
// Sized into the same running total as the declared paths, so an execution
643-
// cannot spend the ceiling twice by both declaring and harvesting. A declared
644-
// path that happens to sit inside the harvest directory is dropped from the
645-
// discovered set rather than counted again — double-billing it would reject a
646-
// single output larger than half the ceiling as oversized.
650+
// cannot spend the byte ceiling twice by both declaring and harvesting. The
651+
// listing applies the same rule to the file-count ceiling and drops a declared
652+
// path that happens to sit inside the harvest directory — double-billing it
653+
// would reject a single output larger than half the ceiling as oversized.
647654
const declaredPaths = new Set(readablePaths)
648-
const discovered = (
649-
req.outputSandboxDir
650-
? await listOutputDirectoryFiles(sandbox, req.outputSandboxDir, options.signal)
651-
: []
652-
).filter((entry) => !declaredPaths.has(entry.path))
655+
const discovered = req.outputSandboxDir
656+
? await listOutputDirectoryFiles(sandbox, req.outputSandboxDir, declaredPaths, options.signal)
657+
: []
653658
for (const entry of discovered) {
654659
totalOutputBytes += entry.size
655660
if (totalOutputBytes > MAX_SANDBOX_OUTPUT_BYTES) {

0 commit comments

Comments
 (0)