mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-31 03:47:12 +08:00
Harden workflow sanitization and Slack setup
This commit is contained in:
+4
-8
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user