Harden workflow sanitization and Slack setup

This commit is contained in:
Siddharth Ganesan
2026-08-13 15:06:40 -07:00
parent 85ffaa7b78
commit f54f7e003f
7 changed files with 163 additions and 12 deletions
@@ -17,13 +17,13 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { SlackIcon } from '@/components/icons'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
import {
useCreateWorkspaceCredential,
useUpdateWorkspaceCredential,
} from '@/hooks/queries/credentials'
import { buildSlackManifest, SLACK_CAPABILITIES } from '@/triggers/slack/capabilities'
import { buildSlackCustomBotRequestUrl } from '@/triggers/webhook-url'
const logger = createLogger('ConnectSlackBotModal')
@@ -109,13 +109,9 @@ export function ConnectSlackBotModal({
}
}, [open, created, isReconnect, initialDisplayName, initialDescription])
// NEXT_PUBLIC_APP_URL, not window.location.origin: Slack's servers must be
// able to reach this URL, so it has to be the app's public base (e.g. the
// tunnel host in dev), not whatever host the browser happens to be on.
const requestUrl = useMemo(
() => `${getBaseUrl()}/api/webhooks/slack/custom/${credentialId}`,
[credentialId]
)
// Shared server-side derivation: uses the app public base (not
// window.location.origin) so Slack's servers can reach it.
const requestUrl = useMemo(() => buildSlackCustomBotRequestUrl(credentialId), [credentialId])
const manifestJson = useMemo(() => {
const manifest = buildSlackManifest(selected, {
@@ -22,7 +22,11 @@ import { BlockType, EDGE, normalizeName } from '@/executor/constants'
import { isAutoModel, isKnownModelId, suggestModelIdsForUnknownModel } from '@/providers/models'
import { isPiByokOnlyMode } from '@/providers/pi-providers'
import { getTool } from '@/tools/utils'
import { TRIGGER_RUNTIME_SUBBLOCK_IDS, TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants'
import {
TRIGGER_ROUTING_FIELD,
TRIGGER_RUNTIME_SUBBLOCK_IDS,
TRIGGER_WEBHOOK_URL_FIELD,
} from '@/triggers/constants'
import type {
EdgeHandleValidationResult,
EditWorkflowOperation,
@@ -75,6 +79,17 @@ export function validateInputsForBlock(
inputs = omit(inputs, [TRIGGER_WEBHOOK_URL_FIELD])
}
if (TRIGGER_ROUTING_FIELD in inputs) {
errors.push({
blockId,
blockType,
field: TRIGGER_ROUTING_FIELD,
value: inputs[TRIGGER_ROUTING_FIELD],
error: `"${TRIGGER_ROUTING_FIELD}" is read-only. Event routing is derived from the selected credential and cannot be edited on the block.`,
})
inputs = omit(inputs, [TRIGGER_ROUTING_FIELD])
}
const blockConfig = getBlock(blockType)
if (!blockConfig) {
+45
View File
@@ -12,6 +12,7 @@ import {
SANDBOX_SELECTABLE_CLI_TOOL_IDS,
} from '@/lib/execution/remote-sandbox/cli-tools'
import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types'
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils'
import { isSubBlockHidden } from '@/lib/workflows/subblocks/visibility'
import { getBlock } from '@/blocks'
@@ -24,6 +25,8 @@ import {
SIM_AUTO_MODEL_ID,
} from '@/providers/models'
import type { ToolConfig, ToolHostingCondition } from '@/tools/types'
import { buildSlackManifest, SLACK_CAPABILITIES } from '@/triggers/slack/capabilities'
import { buildSlackCustomBotRequestUrl } from '@/triggers/webhook-url'
/** The service-account alternative to OAuth for a service, when it offers one. */
export interface VfsServiceAccountAuth {
@@ -733,6 +736,15 @@ export function serializeCredentials(
// credential) — they reconnect differently, so the agent must branch on
// this. Env-var credentials carry no type.
type: a.credentialType,
// Derived, not stored: the public Request URL a Slack custom-bot app
// posts events to. One per credential; every workflow trigger that
// selects this credential shares it. This is what the setup wizard shows
// in Slack's Event Subscriptions step.
...(a.credentialType === 'service_account' &&
a.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID &&
a.id
? { requestUrl: buildSlackCustomBotRequestUrl(a.id) }
: {}),
connectedAt: a.createdAt.toISOString(),
})),
null,
@@ -1137,6 +1149,38 @@ export function serializeIntegrationSchema(
)
}
/**
* Derived setup reference for `slack_oauth` — the same material the custom-bot
* setup wizard shows, surfaced so the copilot can walk a user (or the browser
* agent) through Slack app creation without guessing. None of this is a block
* field: the manifest is a template for api.slack.com, and the Request URL is a
* per-credential property (`requestUrl` in environment/credentials.json).
*/
function slackOAuthSetupReference(): Record<string, unknown> {
const defaults = SLACK_CAPABILITIES.filter((c) => c.defaultChecked).map((c) => c.id)
return {
note:
'Setup reference (derived; NOT block fields). A custom bot is a reusable workspace credential: ' +
'one Slack app, one Request URL, shared by every trigger that selects it. To create or rotate one, ' +
'emit a service_account credential card for provider "slack" — the wizard collects the signing secret ' +
'and bot token without them entering the chat. Existing custom bots appear as service_account ' +
'credentials in environment/credentials.json, each with its requestUrl.',
requestUrlPattern: '{baseUrl}/api/webhooks/slack/custom/{credentialId}',
capabilities: SLACK_CAPABILITIES.map((c) => ({
id: c.id,
label: c.label,
group: c.group,
defaultChecked: c.defaultChecked,
scopes: c.scopes,
events: c.events,
})),
defaultManifest: buildSlackManifest(new Set(defaults), {
appName: 'Sim Bot',
webhookUrl: '<the credential requestUrl>',
}),
}
}
/**
* Serialize a trigger schema for VFS components/triggers/{provider}/{id}.json
*/
@@ -1160,6 +1204,7 @@ export function serializeTriggerSchema(trigger: {
webhook: trigger.webhook || undefined,
subBlocks: trigger.subBlocks.map(serializeSubBlock),
outputs: trigger.outputs,
...(trigger.id === 'slack_oauth' ? { setup: slackOAuthSetupReference() } : {}),
},
null,
2
@@ -5,7 +5,7 @@ import { resetUrlsMock, urlsMockFns } from '@sim/testing'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants'
import { TRIGGER_ROUTING_FIELD, TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants'
beforeAll(() => {
urlsMockFns.mockGetBaseUrl.mockReturnValue('https://sim.test')
@@ -282,3 +282,45 @@ describe('sanitizeForCopilot webhook trigger URL', () => {
expect(result.blocks['gh-1'].inputs ?? {}).not.toHaveProperty(TRIGGER_WEBHOOK_URL_FIELD)
})
})
describe('sanitizeForCopilot credential-routed trigger routing', () => {
it('synthesizes the read-only routing note for a slack_v2 block in trigger mode', () => {
const result = sanitizeForCopilot(
makeSingleBlockWorkflow('slack-1', {
type: 'slack_v2',
name: 'Slack Trigger',
enabled: true,
triggerMode: true,
subBlocks: {
selectedTriggerId: { id: 'selectedTriggerId', type: 'short-input', value: 'slack_oauth' },
customBotCredential: {
id: 'customBotCredential',
type: 'oauth-input',
value: 'cred-123',
},
},
})
)
const routing = result.blocks['slack-1'].inputs?.[TRIGGER_ROUTING_FIELD] as
| Record<string, unknown>
| undefined
expect(routing?.model).toBe('credential-routed')
expect(routing?.selectedCredentialId).toBe('cred-123')
expect(String(routing?.note)).toContain('no per-workflow webhook URL')
expect(result.blocks['slack-1'].inputs ?? {}).not.toHaveProperty(TRIGGER_WEBHOOK_URL_FIELD)
})
it('omits the routing note when the block is not in trigger mode', () => {
const result = sanitizeForCopilot(
makeSingleBlockWorkflow('slack-1', {
type: 'slack_v2',
name: 'Slack Action',
enabled: true,
subBlocks: {},
})
)
expect(result.blocks['slack-1'].inputs ?? {}).not.toHaveProperty(TRIGGER_ROUTING_FIELD)
})
})
@@ -12,8 +12,8 @@ import type {
WorkflowState,
} from '@/stores/workflows/workflow/types'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import { TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants'
import { blockAdvertisesWebhookUrl } from '@/triggers/webhook-url'
import { TRIGGER_ROUTING_FIELD, TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants'
import { blockAdvertisesWebhookUrl, resolveBlockTriggerId } from '@/triggers/webhook-url'
/**
* Sanitized workflow state for copilot (removes all UI-specific data)
@@ -364,6 +364,35 @@ function resolveTriggerWebhookUrl(blockId: string, block: BlockState): string |
}
}
/** Trigger ids that deliver by credential routing — no per-workflow URL exists. */
const CREDENTIAL_ROUTED_TRIGGER_IDS = new Set(['slack_oauth'])
/**
* Derived routing note for trigger blocks that have NO per-workflow webhook URL
* (credential-routed delivery, e.g. Slack v2's `slack_oauth`). Mirrors what the
* setup wizard shows: events arrive at the selected credential's endpoint — a
* custom bot's per-credential Request URL (surfaced as `requestUrl` on that
* credential in environment/credentials.json) or the shared Sim-app endpoint
* routed by Slack workspace. Surfaced as the read-only
* {@link TRIGGER_ROUTING_FIELD} input; rejected on write by `edit_workflow`.
*/
function resolveTriggerRouting(block: BlockState): Record<string, unknown> | null {
const triggerId = resolveBlockTriggerId(block)
if (!triggerId || !CREDENTIAL_ROUTED_TRIGGER_IDS.has(triggerId)) return null
const selected =
block.subBlocks?.customBotCredential?.value ?? block.subBlocks?.manualBotCredential?.value
const selectedCredentialId = typeof selected === 'string' && selected.length > 0 ? selected : null
return {
model: 'credential-routed',
note:
'This trigger has no per-workflow webhook URL. Events are delivered via the selected Slack credential: ' +
'a custom bot posts to its per-credential Request URL (the requestUrl field on that credential in ' +
'environment/credentials.json — the same URL the setup wizard shows for Slack Event Subscriptions); ' +
'a Sim-app connection routes by Slack workspace automatically. Derived at read time; not an editable field.',
...(selectedCredentialId ? { selectedCredentialId } : {}),
}
}
/**
* Convert internal condition handle (condition-{uuid}) to simple format (if, else-if-0, else)
* Uses 0-indexed numbering for else-if conditions
@@ -587,6 +616,10 @@ export function sanitizeForCopilot(
if (webhookUrl) {
inputs[TRIGGER_WEBHOOK_URL_FIELD] = webhookUrl
}
const triggerRouting = resolveTriggerRouting(block)
if (triggerRouting) {
inputs[TRIGGER_ROUTING_FIELD] = triggerRouting
}
}
// Check if this is a loop or parallel (has children)
+10
View File
@@ -40,6 +40,16 @@ export const TRIGGER_RUNTIME_SUBBLOCK_IDS: string[] = [
*/
export const TRIGGER_WEBHOOK_URL_FIELD = 'triggerWebhookUrl'
/**
* Derived, read-only input surfaced on copilot reads of trigger blocks that
* route by CREDENTIAL rather than a per-workflow webhook URL (e.g. Slack v2's
* `slack_oauth`). Explains where events actually arrive — a custom bot's
* per-credential Request URL or the shared Sim-app endpoint — so the copilot
* can answer "where do I point Slack?" without inventing a field. Never
* stored; rejected on write like {@link TRIGGER_WEBHOOK_URL_FIELD}.
*/
export const TRIGGER_ROUTING_FIELD = 'triggerRouting'
/**
* Maximum number of consecutive failures before a trigger (schedule/webhook) is auto-disabled.
* This prevents runaway errors from continuously executing failing workflows.
+10
View File
@@ -12,6 +12,16 @@ export function buildWebhookTriggerUrl(path: string): string {
return `${getBaseUrl()}/api/webhooks/trigger/${path}`
}
/**
* The Request URL a Slack custom-bot app posts events to. One URL per
* credential (not per workflow): the endpoint verifies with the credential's
* signing secret and fans out to every workflow whose trigger routes by this
* credential id. Uses the app's public base so Slack's servers can reach it.
*/
export function buildSlackCustomBotRequestUrl(credentialId: string): string {
return `${getBaseUrl()}/api/webhooks/slack/custom/${credentialId}`
}
function subBlockValue(block: BlockState, subBlockId: string): unknown {
return block.subBlocks?.[subBlockId]?.value
}