From ed9bdc8313ea0e5914c9f3204912e9a0d6c2605e Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Fri, 21 Aug 2026 09:12:18 +0200 Subject: [PATCH 1/4] feat: add app-trigger and Slack approval primitives --- README.md | 55 +++ packages/delivery/CHANGELOG.md | 5 + packages/delivery/src/index.ts | 13 + packages/delivery/src/slack-approval.test.ts | 182 ++++++++++ packages/delivery/src/slack-approval.ts | 356 +++++++++++++++++++ packages/delivery/src/slack.test.ts | 70 ++++ packages/delivery/src/slack.ts | 73 ++++ packages/runtime/CHANGELOG.md | 6 + packages/runtime/src/app-trigger.test.ts | 89 +++++ packages/runtime/src/app-trigger.ts | 77 ++++ packages/runtime/src/index.ts | 9 + 11 files changed, 935 insertions(+) create mode 100644 packages/delivery/src/slack-approval.test.ts create mode 100644 packages/delivery/src/slack-approval.ts create mode 100644 packages/runtime/src/app-trigger.test.ts create mode 100644 packages/runtime/src/app-trigger.ts diff --git a/README.md b/README.md index 84f7af2e..88d9c052 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,61 @@ Every provider an agent triggers on must also appear in `persona.integrations` GitHub event, Linear issue, Slack mention, Notion update, or Jira event arrives. See [`examples/review-agent`](./examples/review-agent/) for a complete example. +### Manual payloads and reaction-approved actions + +Cloud delivers an authenticated manual trigger as a `cron.tick`, just like a +schedule. Use the runtime discriminator to preserve the difference and surface +accepted-but-malformed trigger bodies instead of silently treating them as +clock ticks: + +```ts +import { readAppTriggerIntent } from '@agentworkforce/runtime'; + +const intent = await readAppTriggerIntent(event); +if (intent.kind === 'malformed-app-trigger') { + throw new Error(`Invalid app trigger: ${intent.reason}`); +} +if (intent.kind === 'app-trigger') { + await handleManualPayload(intent.payload); +} else { + await handleSchedule(); +} +``` + +For an action the user approves by reacting to a Slack card, +`@agentworkforce/delivery` owns the provider mechanics: normalized reaction +parsing, bounded hidden action identifiers, metadata-redaction recovery, and +exact actor/emoji/message binding. The agent still owns the domain action: + +```ts +import { + buildSlackApprovalCard, + matchSlackApprovalReaction, + readSlackReaction +} from '@agentworkforce/delivery'; + +const card = buildSlackApprovalCard({ + namespace: 'inbox.archive', + approverId: ownerSlackId, + actionIds: threadIds, + text: 'React :white_check_mark: to archive these conversations.', + validateActionId: isValidThreadId +}); +// Post card.text + card.metadata + card.blocks through the authenticated Slack +// integration and retain the returned message timestamp. + +const reaction = readSlackReaction((await event.expand('full')).data); +if (reaction) { + // Fetch exactly reaction.channel + reaction.messageTs before matching. + const approval = matchSlackApprovalReaction(reaction, reactedMessage, { + namespace: 'inbox.archive', + approverId: ownerSlackId, + validateActionId: isValidThreadId + }); + if (approval) await archiveThreads(approval.actionIds); +} +``` + ## Run modes `workforce deploy ` defaults to the best available runner mode. diff --git a/packages/delivery/CHANGELOG.md b/packages/delivery/CHANGELOG.md index 456f4c2d..6914d007 100644 --- a/packages/delivery/CHANGELOG.md +++ b/packages/delivery/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add normalized Slack reaction parsing and reusable approval-card utilities + that bind bounded hidden action ids to an exact message, approver, and emoji. + ## [4.1.41] - 2026-08-14 ### Released diff --git a/packages/delivery/src/index.ts b/packages/delivery/src/index.ts index 40c6a7d6..30cc4fd9 100644 --- a/packages/delivery/src/index.ts +++ b/packages/delivery/src/index.ts @@ -27,17 +27,30 @@ export { linkSlackMentions, loadSlackUsers, readSlackMessage, + readSlackReaction, requireSlackReceipt, resolveSlackUserId, slackSkipReason, stripSlackLeadingMention, type SlackInboundMessage, + type SlackReaction, type SlackMentionIndex, type SlackUser, type SlackUsersOptions, type SlackUsersWarning } from './slack.js'; +export { + buildSlackApprovalCard, + matchSlackApprovalReaction, + readSlackApproval, + type MatchSlackApprovalOptions, + type ReadSlackApprovalOptions, + type SlackApproval, + type SlackApprovalCard, + type SlackApprovalCardOptions +} from './slack-approval.js'; + export { input, list, withTimeout, fetchWithTimeout } from './helpers.js'; export { diff --git a/packages/delivery/src/slack-approval.test.ts b/packages/delivery/src/slack-approval.test.ts new file mode 100644 index 00000000..5c59d8ca --- /dev/null +++ b/packages/delivery/src/slack-approval.test.ts @@ -0,0 +1,182 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildSlackApprovalCard, + matchSlackApprovalReaction, + readSlackApproval +} from './slack-approval.js'; +import type { SlackReaction } from './slack.js'; + +const options = { + namespace: 'github-inbox.archive', + approverId: 'U12345678' +} as const; + +test('buildSlackApprovalCard keeps exact ids out of rendered text and decodes them', () => { + const card = buildSlackApprovalCard({ + ...options, + text: 'React :white_check_mark: to archive these conversations.', + actionIds: ['thread:one', 'thread,two', 'thread:one'], + context: { batch: 2 } + }); + + const renderedText = card.blocks + .filter((block) => block.type === 'section') + .map((block) => JSON.stringify(block.text)) + .join('\n'); + assert.doesNotMatch(renderedText, /thread:one|thread,two/); + assert.deepEqual( + readSlackApproval({ ts: '1787300000.000100', ...card }, options), + { + namespace: 'github-inbox.archive', + approverId: 'U12345678', + actionIds: ['thread:one', 'thread,two'], + context: { batch: 2 } + } + ); +}); + +test('readSlackApproval recovers from Slack-redacted metadata through hidden block ids', () => { + const card = buildSlackApprovalCard({ + ...options, + text: 'Approve the batch.', + actionIds: ['thread-1', 'thread-2'] + }); + + assert.deepEqual( + readSlackApproval({ + ts: '1787300000.000100', + metadata: { + event_type: card.metadata.event_type, + event_payload: {} + }, + blocks: card.blocks + }, options), + { + namespace: 'github-inbox.archive', + approverId: 'U12345678', + actionIds: ['thread-1', 'thread-2'] + } + ); +}); + +test('matchSlackApprovalReaction binds actor, emoji, and exact message timestamp', () => { + const card = buildSlackApprovalCard({ + ...options, + text: 'Approve the batch.', + actionIds: ['thread-1'] + }); + const reaction: SlackReaction = { + channel: 'D12345678', + messageTs: '1787300000.000100', + actorId: 'U12345678', + emoji: 'white_check_mark' + }; + const message = { ts: reaction.messageTs, ...card }; + + assert.deepEqual(matchSlackApprovalReaction(reaction, message, options)?.actionIds, ['thread-1']); + assert.equal( + matchSlackApprovalReaction({ ...reaction, actorId: 'U87654321' }, message, options), + null + ); + assert.equal( + matchSlackApprovalReaction({ ...reaction, emoji: 'eyes' }, message, options), + null + ); + assert.equal( + matchSlackApprovalReaction(reaction, { ...message, ts: '1787300002.000300' }, options), + null + ); +}); + +test('Slack approval cards enforce domain validators and fail closed on tampering', () => { + const validateActionId = (id: string) => /^thread-[0-9]+$/.test(id); + assert.throws( + () => buildSlackApprovalCard({ + ...options, + text: 'Approve.', + actionIds: ['message-1'], + validateActionId + }), + /Invalid Slack approval action id/ + ); + + const card = buildSlackApprovalCard({ + ...options, + text: 'Approve.', + actionIds: ['thread-1'], + validateActionId + }); + const block = card.blocks.find((value) => typeof value.block_id === 'string'); + assert.ok(block); + assert.equal( + readSlackApproval({ + metadata: { event_type: card.metadata.event_type, event_payload: {} }, + blocks: [{ ...block, block_id: `${String(block.block_id).replace(/thread-1$/, '')}%E0%A4%A` }] + }, { ...options, validateActionId }), + null + ); + assert.equal( + readSlackApproval({ ...card }, { ...options, approverId: 'U87654321' }), + null + ); + + const inconsistent = { + ...card, + metadata: { + ...card.metadata, + event_payload: { + ...card.metadata.event_payload, + action_ids: ['thread-2'] + } + } + }; + assert.equal( + readSlackApproval(inconsistent, { ...options, validateActionId }), + null + ); +}); + +test('Slack approval cards enforce metadata, block-id, and section limits', () => { + assert.throws( + () => buildSlackApprovalCard({ + ...options, + text: 'Approve.', + actionIds: ['thread-1'], + context: { note: 'x'.repeat(4_000) } + }), + /metadata is too large/ + ); + assert.throws( + () => buildSlackApprovalCard({ + ...options, + text: 'Approve.', + actionIds: ['x'.repeat(300)] + }), + /too large for a block id/ + ); + + const card = buildSlackApprovalCard({ + ...options, + text: 'x'.repeat(6_100), + actionIds: ['thread-1'] + }); + const sections = card.blocks.filter((block) => block.type === 'section'); + assert.equal(sections.length, 3); + for (const section of sections) { + const text = section.text as { text: string }; + assert.ok(text.text.length <= 2_900); + } + + const emojiText = '🙂'.repeat(3_000); + const emojiCard = buildSlackApprovalCard({ + ...options, + text: emojiText, + actionIds: ['thread-1'] + }); + const emojiSections = emojiCard.blocks + .filter((block) => block.type === 'section') + .map((block) => (block.text as { text: string }).text); + assert.equal(emojiSections.join(''), emojiText); + assert.ok(emojiSections.every((text) => Array.from(text).length <= 2_900)); +}); diff --git a/packages/delivery/src/slack-approval.ts b/packages/delivery/src/slack-approval.ts new file mode 100644 index 00000000..41598535 --- /dev/null +++ b/packages/delivery/src/slack-approval.ts @@ -0,0 +1,356 @@ +import type { SlackReaction } from './slack.js'; + +const APPROVAL_METADATA_EVENT = 'agentworkforce_slack_approval_v1'; +const MAX_METADATA_BYTES = 3_800; +const MAX_BLOCK_ID_BYTES = 255; +const MAX_BLOCKS = 50; +const MAX_SECTION_TEXT = 2_900; + +export interface SlackApprovalCardOptions { + /** Stable agent/action namespace, for example `github-inbox.archive`. */ + namespace: string; + /** The only Slack member allowed to approve this card. */ + approverId: string; + /** Exact non-secret opaque identifiers the approved action will receive. */ + actionIds: readonly string[]; + /** User-visible Slack mrkdwn. Action identifiers are never appended to it. */ + text: string; + /** Optional non-secret state used when interpreting the action. */ + context?: Record; + /** Optional domain validator applied before identifiers enter the card. */ + validateActionId?: (id: string) => boolean; +} + +export interface SlackApprovalCard { + text: string; + metadata: { + event_type: typeof APPROVAL_METADATA_EVENT; + event_payload: { + namespace: string; + approver_id: string; + action_ids: string[]; + context?: Record; + }; + }; + blocks: Array>; +} + +export interface ReadSlackApprovalOptions { + namespace: string; + approverId: string; + validateActionId?: (id: string) => boolean; +} + +export interface MatchSlackApprovalOptions extends ReadSlackApprovalOptions { + /** Slack emoji name, with or without surrounding colons. */ + emoji?: string; +} + +export interface SlackApproval { + namespace: string; + approverId: string; + actionIds: string[]; + context?: Record; +} + +/** + * Build a Slack approval card whose exact action identifiers are non-rendered + * message state rather than visible text. They are not encrypted and must not + * contain secrets. + * + * Identifiers are stored in message metadata and redundantly encoded into + * non-rendered block ids. The block copy lets an agent recover safely when + * Slack returns the metadata event type but redacts its payload. Every Slack + * size limit is checked before the card is returned. + */ +export function buildSlackApprovalCard( + options: SlackApprovalCardOptions +): SlackApprovalCard { + const { namespace, approverId } = approvalIdentity(options); + if (!options.text.trim()) throw new Error('Slack approval card text is required'); + const actionIds = normalizeActionIds(options.actionIds, options.validateActionId); + if (actionIds.length === 0) { + throw new Error('Slack approval card requires at least one action id'); + } + + const eventPayload: SlackApprovalCard['metadata']['event_payload'] = { + namespace, + approver_id: approverId, + action_ids: actionIds, + ...(options.context ? { context: options.context } : {}) + }; + const metadata: SlackApprovalCard['metadata'] = { + event_type: APPROVAL_METADATA_EVENT, + event_payload: eventPayload + }; + const encodedMetadata = stringifyMetadata(metadata); + if (Buffer.byteLength(encodedMetadata, 'utf8') > MAX_METADATA_BYTES) { + throw new Error( + `Slack approval metadata is too large (${Buffer.byteLength(encodedMetadata, 'utf8')} bytes)` + ); + } + + const blocks: Array> = splitSlackSectionText(options.text).map((text) => ({ + type: 'section', + text: { type: 'mrkdwn', text } + })); + for (const blockId of approvalBlockIds(namespace, approverId, actionIds)) { + blocks.push({ + type: 'context', + block_id: blockId, + elements: [{ type: 'plain_text', text: '\u2063', emoji: false }] + }); + } + if (blocks.length > MAX_BLOCKS) { + throw new Error(`Slack approval card requires too many blocks (${blocks.length})`); + } + return { text: options.text, metadata, blocks }; +} + +/** + * Decode action identifiers from a Slack message created by + * {@link buildSlackApprovalCard}. The expected namespace and approver are + * caller-supplied, so a card for another action or user cannot be replayed. + * + * Metadata and block-id copies are combined when both are present. Malformed + * identifiers fail the entire card rather than being silently discarded. + */ +export function readSlackApproval( + message: unknown, + options: ReadSlackApprovalOptions +): SlackApproval | null { + const { namespace, approverId } = approvalIdentity(options); + const record = asRecord(message); + if (!record) return null; + + const metadata = asRecord(record.metadata); + const eventPayload = asRecord(metadata?.event_payload); + const standardMetadata = metadata?.event_type === APPROVAL_METADATA_EVENT; + const metadataHasIdentity = eventPayload !== null && ( + 'namespace' in eventPayload || 'approver_id' in eventPayload + ); + const metadataMatches = standardMetadata + && eventPayload?.namespace === namespace + && eventPayload.approver_id === approverId; + + // A standard card that names a different action/user is inconsistent with + // the requested approval, even if somebody also inserted a matching block. + if (standardMetadata && metadataHasIdentity && !metadataMatches) return null; + + let metadataIds: string[] | null = null; + if (metadataMatches && 'action_ids' in eventPayload) { + if (!Array.isArray(eventPayload.action_ids)) return null; + if (!eventPayload.action_ids.every((id) => typeof id === 'string')) return null; + metadataIds = normalizeDecodedActionIds( + eventPayload.action_ids, + options.validateActionId + ); + if (!metadataIds || metadataIds.length === 0) return null; + } + + const prefix = approvalBlockPrefix(namespace, approverId); + let matchingBlock = false; + const blockIds: string[] = []; + const blocks = Array.isArray(record.blocks) ? record.blocks : []; + for (const value of blocks) { + const block = asRecord(value); + const blockId = typeof block?.block_id === 'string' ? block.block_id : undefined; + if (!blockId?.startsWith(prefix)) continue; + matchingBlock = true; + const decoded = decodeBlockIds(blockId.slice(prefix.length)); + if (!decoded) return null; + blockIds.push(...decoded); + } + + if (!metadataMatches && !matchingBlock) return null; + const normalizedBlockIds = matchingBlock + ? normalizeDecodedActionIds(blockIds, options.validateActionId) + : null; + if (matchingBlock && (!normalizedBlockIds || normalizedBlockIds.length === 0)) return null; + if (metadataIds && normalizedBlockIds && !sameStrings(metadataIds, normalizedBlockIds)) { + return null; + } + const actionIds = metadataIds ?? normalizedBlockIds; + if (!actionIds) return null; + const context = metadataMatches ? asRecord(eventPayload.context) : null; + return { + namespace, + approverId, + actionIds, + ...(context ? { context } : {}) + }; +} + +/** + * Bind a decoded card to the exact reaction that approved it. + * + * Callers fetch the reacted message using `reaction.channel` and + * `reaction.messageTs`, then pass that result here. A different actor, emoji, + * or message timestamp fails closed. + */ +export function matchSlackApprovalReaction( + reaction: SlackReaction, + message: unknown, + options: MatchSlackApprovalOptions +): SlackApproval | null { + const emoji = normalizeEmoji(options.emoji ?? 'white_check_mark'); + if (reaction.actorId !== options.approverId || reaction.emoji !== emoji) return null; + const messageRecord = asRecord(message); + if (typeof messageRecord?.ts !== 'string' || messageRecord.ts !== reaction.messageTs) { + return null; + } + return readSlackApproval(message, options); +} + +function approvalIdentity(options: { + namespace: string; + approverId: string; +}): { namespace: string; approverId: string } { + const namespace = options.namespace.trim(); + const approverId = options.approverId.trim(); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(namespace)) { + throw new Error('Slack approval namespace must be 1-64 URL-safe characters'); + } + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(approverId)) { + throw new Error('Slack approval approver id must be 1-128 URL-safe characters'); + } + return { namespace, approverId }; +} + +function normalizeActionIds( + values: readonly string[], + validate: ((id: string) => boolean) | undefined +): string[] { + const output: string[] = []; + const seen = new Set(); + for (const value of values) { + const id = value.trim(); + if (!id || validate?.(id) === false) { + throw new Error(`Invalid Slack approval action id: ${JSON.stringify(value)}`); + } + if (!seen.has(id)) { + seen.add(id); + output.push(id); + } + } + return output; +} + +function normalizeDecodedActionIds( + values: readonly string[], + validate: ((id: string) => boolean) | undefined +): string[] | null { + const output: string[] = []; + const seen = new Set(); + for (const value of values) { + const id = value.trim(); + if (!id || validate?.(id) === false) return null; + if (!seen.has(id)) { + seen.add(id); + output.push(id); + } + } + return output; +} + +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function approvalBlockIds( + namespace: string, + approverId: string, + actionIds: readonly string[] +): string[] { + const prefix = approvalBlockPrefix(namespace, approverId); + const output: string[] = []; + let chunk: string[] = []; + for (const id of actionIds) { + const encoded = encodeURIComponent(id); + const candidate = `${prefix}${[...chunk, encoded].join(',')}`; + if (Buffer.byteLength(candidate, 'utf8') <= MAX_BLOCK_ID_BYTES) { + chunk.push(encoded); + continue; + } + if (chunk.length === 0) { + throw new Error(`Slack approval action id is too large for a block id: ${JSON.stringify(id)}`); + } + output.push(`${prefix}${chunk.join(',')}`); + chunk = [encoded]; + if (Buffer.byteLength(`${prefix}${encoded}`, 'utf8') > MAX_BLOCK_ID_BYTES) { + throw new Error(`Slack approval action id is too large for a block id: ${JSON.stringify(id)}`); + } + } + if (chunk.length > 0) output.push(`${prefix}${chunk.join(',')}`); + return output; +} + +function approvalBlockPrefix(namespace: string, approverId: string): string { + return `aw-approval-v1:${namespace}:${approverId}:`; +} + +function decodeBlockIds(value: string): string[] | null { + if (!value) return null; + try { + const encoded = value.split(','); + if (encoded.some((id) => !id)) return null; + return encoded.map((id) => decodeURIComponent(id)); + } catch { + return null; + } +} + +function splitSlackSectionText(text: string): string[] { + const output: string[] = []; + let current = ''; + for (const line of text.split('\n')) { + const candidate = current ? `${current}\n${line}` : line; + if (textLength(candidate) <= MAX_SECTION_TEXT) { + current = candidate; + continue; + } + if (current) { + output.push(current); + current = ''; + } + const pieces = splitLongText(line, MAX_SECTION_TEXT); + output.push(...pieces.slice(0, -1)); + current = pieces.at(-1) ?? ''; + } + if (current) output.push(current); + return output; +} + +function splitLongText(text: string, limit: number): string[] { + const characters = Array.from(text); + if (characters.length <= limit) return [text]; + const output: string[] = []; + for (let offset = 0; offset < characters.length; offset += limit) { + output.push(characters.slice(offset, offset + limit).join('')); + } + return output; +} + +function textLength(text: string): number { + return Array.from(text).length; +} + +function stringifyMetadata(metadata: SlackApprovalCard['metadata']): string { + try { + return JSON.stringify(metadata); + } catch (error) { + throw new Error( + `Slack approval metadata is not JSON-serializable: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +function normalizeEmoji(value: string): string { + return value.trim().replaceAll(':', ''); +} + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : null; +} diff --git a/packages/delivery/src/slack.test.ts b/packages/delivery/src/slack.test.ts index 6b4669bf..9255dc22 100644 --- a/packages/delivery/src/slack.test.ts +++ b/packages/delivery/src/slack.test.ts @@ -12,12 +12,82 @@ import { isSlackChannelId, linkSlackMentions, loadSlackUsers, + readSlackReaction, requireSlackReceipt, resolveSlackUserId, type SlackUser } from './slack.js'; import type { WorkforceCtx } from '@agentworkforce/runtime'; +test('readSlackReaction normalizes wrapped message reactions', () => { + assert.deepEqual( + readSlackReaction({ + data: { + raw_event: { + item: { + type: 'message', + channel: 'C12345678__engineering', + ts: '1787300000.000100' + }, + user: 'U12345678', + reaction: ':white_check_mark:', + event_ts: '1787300001.000200' + } + } + }), + { + channel: 'C12345678', + messageTs: '1787300000.000100', + actorId: 'U12345678', + emoji: 'white_check_mark', + eventTs: '1787300001.000200' + } + ); +}); + +test('readSlackReaction accepts event wrappers and rejects unusable reaction items', () => { + assert.deepEqual( + readSlackReaction({ + event: { + item: { channel: 'D12345678', ts: '1787300000.000100' }, + user_id: 'U12345678', + reaction: 'eyes' + } + }), + { + channel: 'D12345678', + messageTs: '1787300000.000100', + actorId: 'U12345678', + emoji: 'eyes' + } + ); + assert.equal( + readSlackReaction({ + item: { type: 'file', channel: 'C12345678', ts: '1787300000.000100' }, + user: 'U12345678', + reaction: 'white_check_mark' + }), + null + ); + assert.equal( + readSlackReaction({ + item: { type: 'message', channel: 'C12345678' }, + user: 'U12345678', + reaction: 'white_check_mark' + }), + null + ); + assert.equal( + readSlackReaction({ + channel: 'C12345678', + ts: '1787300001.000200', + user: 'U12345678', + reaction: 'white_check_mark' + }), + null + ); +}); + test('loadSlackUsers prefers the compact index and excludes bots and Slackbot', async (t) => { const root = await tempMount(t); const usersDir = path.join(root, 'slack', 'users'); diff --git a/packages/delivery/src/slack.ts b/packages/delivery/src/slack.ts index 4d6a474a..816e944d 100644 --- a/packages/delivery/src/slack.ts +++ b/packages/delivery/src/slack.ts @@ -19,6 +19,15 @@ export interface SlackInboundMessage { subtype?: string; } +export interface SlackReaction { + channel: string; + messageTs: string; + actorId: string; + /** Normalized Slack emoji name without surrounding colons. */ + emoji: string; + eventTs?: string; +} + function slackAsRecord(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) @@ -91,6 +100,48 @@ export function readSlackMessage(payload: unknown): SlackInboundMessage | null { }; } +/** + * Parse a Slack reaction envelope into the exact message, actor, and emoji + * needed by deterministic approval handlers. + * + * Cloud, Relayfile, and direct Slack fixtures have historically added + * `data`, `event`, or `raw_event` wrappers. This parser accepts those known + * shapes, but still requires an actor plus a message channel/timestamp and + * rejects reactions to non-message items. + */ +export function readSlackReaction(payload: unknown): SlackReaction | null { + const root = slackAsRecord(payload); + if (!root) return null; + const data = slackAsRecord(root.data) ?? root; + const event = slackAsRecord(data.event) ?? data; + const raw = slackAsRecord(event.raw_event) ?? slackAsRecord(data.raw_event) ?? event; + const candidates = [raw, event, data, root]; + const item = firstSlackRecord(candidates, 'item'); + const itemType = slackStr(item?.type) ?? firstSlackString(candidates, 'item_type'); + if (itemType && itemType !== 'message') return null; + + const channel = slackStr(item?.channel) ?? firstSlackString(candidates, 'channel'); + // Do not fall back to a generic top-level `ts`: on raw reaction events that + // can be the reaction event time rather than the reacted message. An item ts + // or explicitly named message_ts is required for exact-message binding. + const messageTs = slackStr(item?.ts) ?? firstSlackString(candidates, 'message_ts'); + const actorId = firstSlackString(candidates, 'user') + ?? firstSlackString(candidates, 'user_id'); + const reaction = firstSlackString(candidates, 'reaction'); + if (!channel || !messageTs || !actorId || !reaction) return null; + + const emoji = reaction.trim().replaceAll(':', ''); + if (!emoji) return null; + const eventTs = firstSlackString(candidates, 'event_ts'); + return { + channel: bareSlackChannelId(channel), + messageTs, + actorId, + emoji, + ...(eventTs ? { eventTs } : {}) + }; +} + /** * Return why an inbound Slack message must be ignored, or null to handle it. * @@ -332,6 +383,28 @@ function isSlackUserIndexRow(value: unknown): value is SlackUserIndexRow & { id: return typeof value.id === 'string' && value.id.length > 0; } +function firstSlackString( + records: ReadonlyArray>, + key: string +): string | undefined { + for (const record of records) { + const value = slackStr(record[key]); + if (value) return value; + } + return undefined; +} + +function firstSlackRecord( + records: ReadonlyArray>, + key: string +): Record | null { + for (const record of records) { + const value = slackAsRecord(record[key]); + if (value) return value; + } + return null; +} + function isSlackbot(id: unknown, handle: unknown): boolean { if (typeof id === 'string' && id.toUpperCase() === 'USLACKBOT') return true; return typeof handle === 'string' && handle.trim().toLowerCase() === 'slackbot'; diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md index ca0bf244..d4fa4686 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `readAppTriggerIntent()` so agents can distinguish scheduled ticks, + authenticated app-trigger payloads, and malformed accepted trigger bodies + without reimplementing gateway-wrapper parsing. + ## [4.1.41] - 2026-08-14 ### Changed diff --git a/packages/runtime/src/app-trigger.test.ts b/packages/runtime/src/app-trigger.test.ts new file mode 100644 index 00000000..78b65158 --- /dev/null +++ b/packages/runtime/src/app-trigger.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { AgentEvent } from './types.js'; +import { readAppTriggerIntent } from './app-trigger.js'; +import { envelopeToAgentEvent } from './to-agent-event.js'; + +test('readAppTriggerIntent returns the caller payload from an app-triggered cron event', async () => { + const event = envelopeToAgentEvent({ + id: 'app-trigger-1', + workspace: 'workspace-1', + type: 'cron.tick', + occurredAt: '2026-08-21T10:00:00Z', + name: 'manual', + resource: { + source: 'app.trigger', + payload: { accountId: 'acme', reason: 'usage_spike' } + } + }); + assert.ok(event); + + assert.deepEqual(await readAppTriggerIntent(event), { + kind: 'app-trigger', + payload: { accountId: 'acme', reason: 'usage_spike' } + }); +}); + +test('readAppTriggerIntent distinguishes scheduled ticks from app triggers', async () => { + const event = envelopeToAgentEvent({ + id: 'scheduled-1', + workspace: 'workspace-1', + type: 'cron.tick', + occurredAt: '2026-08-21T10:00:00Z', + name: 'hourly', + cron: '0 * * * *' + }); + assert.ok(event); + + assert.deepEqual(await readAppTriggerIntent(event), { kind: 'not-app-trigger' }); +}); + +test('readAppTriggerIntent does not mistake provider data for an app trigger', async () => { + const event = envelopeToAgentEvent({ + id: 'github-1', + workspace: 'workspace-1', + type: 'github.issue.opened', + occurredAt: '2026-08-21T10:00:00Z', + resource: { issue: { number: 42 } } + }); + assert.ok(event); + + assert.deepEqual(await readAppTriggerIntent(event), { kind: 'not-app-trigger' }); +}); + +test('readAppTriggerIntent reports missing and non-object payloads as malformed', async () => { + const eventWith = (data: unknown) => ({ + expand: async () => ({ data }) + }) as unknown as Pick; + + assert.deepEqual( + await readAppTriggerIntent(eventWith({ source: 'app.trigger' })), + { kind: 'malformed-app-trigger', reason: 'missing-payload' } + ); + assert.deepEqual( + await readAppTriggerIntent(eventWith({ source: 'app.trigger', payload: [] })), + { kind: 'malformed-app-trigger', reason: 'payload-not-object', payload: [] } + ); + assert.deepEqual( + await readAppTriggerIntent(eventWith({ source: 'app.trigger', payload: null })), + { kind: 'malformed-app-trigger', reason: 'payload-not-object', payload: null } + ); +}); + +test('readAppTriggerIntent accepts the older nested resource wrapper', async () => { + const event = { + expand: async () => ({ + data: { + resource: { + source: 'app.trigger', + payload: { mode: 'backfill' } + } + } + }) + } as unknown as Pick; + + assert.deepEqual(await readAppTriggerIntent(event), { + kind: 'app-trigger', + payload: { mode: 'backfill' } + }); +}); diff --git a/packages/runtime/src/app-trigger.ts b/packages/runtime/src/app-trigger.ts new file mode 100644 index 00000000..64f28bbd --- /dev/null +++ b/packages/runtime/src/app-trigger.ts @@ -0,0 +1,77 @@ +import type { AgentEvent } from './types.js'; + +export type MalformedAppTriggerReason = + | 'missing-payload' + | 'payload-not-object'; + +/** + * A normalized manual/app-trigger wake-up. + * + * Cloud intentionally delivers app triggers as `cron.tick` events, so event + * type alone cannot distinguish a scheduled run from a caller-supplied JSON + * body. This union preserves that distinction and keeps malformed accepted + * requests visible to the agent instead of silently treating them as clock + * ticks. + */ +export type AppTriggerIntent = + | { kind: 'not-app-trigger' } + | { kind: 'app-trigger'; payload: Record } + | { + kind: 'malformed-app-trigger'; + reason: MalformedAppTriggerReason; + payload?: unknown; + }; + +/** + * Read Cloud's authenticated app-trigger wrapper from a normalized event. + * + * Current v4 events expose the original envelope through + * `event.expand('full').data`. One nested `resource` wrapper is also accepted + * for compatibility with older gateway fixtures. Bare objects are never + * treated as app triggers: the explicit `source: "app.trigger"` marker is + * required, which prevents ordinary provider payloads and scheduled ticks + * from being misclassified. + * + * A scheduled tick has no full expansion, so expansion rejection means + * `not-app-trigger`. Once the marker is present, however, an absent or + * non-object payload is reported as malformed rather than downgraded to a + * schedule firing. + */ +export async function readAppTriggerIntent( + event: Pick +): Promise { + let data: unknown; + try { + data = (await event.expand('full')).data; + } catch { + return { kind: 'not-app-trigger' }; + } + + const wrapper = appTriggerWrapper(data); + if (!wrapper) return { kind: 'not-app-trigger' }; + if (!Object.prototype.hasOwnProperty.call(wrapper, 'payload')) { + return { kind: 'malformed-app-trigger', reason: 'missing-payload' }; + } + + const payload = wrapper.payload; + if (!isRecord(payload)) { + return { + kind: 'malformed-app-trigger', + reason: 'payload-not-object', + payload + }; + } + return { kind: 'app-trigger', payload }; +} + +function appTriggerWrapper(value: unknown): Record | null { + const outer = isRecord(value) ? value : null; + if (!outer) return null; + if (outer.source === 'app.trigger') return outer; + const nested = isRecord(outer.resource) ? outer.resource : null; + return nested?.source === 'app.trigger' ? nested : null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index aea40033..cf5277b7 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -80,6 +80,15 @@ export { type NormalizedCronFire } from './cron.js'; +// Authenticated manual/app-trigger decoding. Cloud delivers these as cron.tick +// events, so consumers need an explicit marker-aware discriminator rather than +// branching on event.type or treating expansion success as sufficient. +export { + readAppTriggerIntent, + type AppTriggerIntent, + type MalformedAppTriggerReason +} from './app-trigger.js'; + // Relay (agent-to-agent) client used by ctx.relay; exported for external ctx // builders and tests. export { buildRelayContext, DEFAULT_RELAYCAST_URL } from './relay.js'; From 853ae9ed7a017b7a8d66a769e73e097039162bc2 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Fri, 21 Aug 2026 09:20:07 +0200 Subject: [PATCH 2/4] fix(delivery): harden Slack approval reactions --- packages/delivery/src/slack-approval.test.ts | 7 ++++- packages/delivery/src/slack-approval.ts | 21 ++++++++++++-- packages/delivery/src/slack.test.ts | 30 ++++++++++++++++++++ packages/delivery/src/slack.ts | 19 +++++++++++++ 4 files changed, 73 insertions(+), 4 deletions(-) diff --git a/packages/delivery/src/slack-approval.test.ts b/packages/delivery/src/slack-approval.test.ts index 5c59d8ca..4ba6ae5f 100644 --- a/packages/delivery/src/slack-approval.test.ts +++ b/packages/delivery/src/slack-approval.test.ts @@ -67,6 +67,7 @@ test('matchSlackApprovalReaction binds actor, emoji, and exact message timestamp actionIds: ['thread-1'] }); const reaction: SlackReaction = { + action: 'added', channel: 'D12345678', messageTs: '1787300000.000100', actorId: 'U12345678', @@ -87,6 +88,10 @@ test('matchSlackApprovalReaction binds actor, emoji, and exact message timestamp matchSlackApprovalReaction(reaction, { ...message, ts: '1787300002.000300' }, options), null ); + assert.equal( + matchSlackApprovalReaction({ ...reaction, action: 'removed' }, message, options), + null + ); }); test('Slack approval cards enforce domain validators and fail closed on tampering', () => { @@ -145,7 +150,7 @@ test('Slack approval cards enforce metadata, block-id, and section limits', () = actionIds: ['thread-1'], context: { note: 'x'.repeat(4_000) } }), - /metadata is too large/ + /event payload is too large/ ); assert.throws( () => buildSlackApprovalCard({ diff --git a/packages/delivery/src/slack-approval.ts b/packages/delivery/src/slack-approval.ts index 41598535..92a56c2c 100644 --- a/packages/delivery/src/slack-approval.ts +++ b/packages/delivery/src/slack-approval.ts @@ -1,6 +1,7 @@ import type { SlackReaction } from './slack.js'; const APPROVAL_METADATA_EVENT = 'agentworkforce_slack_approval_v1'; +const MAX_EVENT_PAYLOAD_CHARACTERS = 3_000; const MAX_METADATA_BYTES = 3_800; const MAX_BLOCK_ID_BYTES = 255; const MAX_BLOCKS = 50; @@ -83,6 +84,12 @@ export function buildSlackApprovalCard( event_type: APPROVAL_METADATA_EVENT, event_payload: eventPayload }; + const encodedEventPayload = stringifyApprovalJson(eventPayload, 'event payload'); + if (textLength(encodedEventPayload) > MAX_EVENT_PAYLOAD_CHARACTERS) { + throw new Error( + `Slack approval event payload is too large (${textLength(encodedEventPayload)} characters)` + ); + } const encodedMetadata = stringifyMetadata(metadata); if (Buffer.byteLength(encodedMetadata, 'utf8') > MAX_METADATA_BYTES) { throw new Error( @@ -194,7 +201,11 @@ export function matchSlackApprovalReaction( options: MatchSlackApprovalOptions ): SlackApproval | null { const emoji = normalizeEmoji(options.emoji ?? 'white_check_mark'); - if (reaction.actorId !== options.approverId || reaction.emoji !== emoji) return null; + if ( + reaction.action !== 'added' + || reaction.actorId !== options.approverId + || reaction.emoji !== emoji + ) return null; const messageRecord = asRecord(message); if (typeof messageRecord?.ts !== 'string' || messageRecord.ts !== reaction.messageTs) { return null; @@ -336,11 +347,15 @@ function textLength(text: string): number { } function stringifyMetadata(metadata: SlackApprovalCard['metadata']): string { + return stringifyApprovalJson(metadata, 'metadata'); +} + +function stringifyApprovalJson(value: unknown, label: string): string { try { - return JSON.stringify(metadata); + return JSON.stringify(value); } catch (error) { throw new Error( - `Slack approval metadata is not JSON-serializable: ${error instanceof Error ? error.message : String(error)}` + `Slack approval ${label} is not JSON-serializable: ${error instanceof Error ? error.message : String(error)}` ); } } diff --git a/packages/delivery/src/slack.test.ts b/packages/delivery/src/slack.test.ts index 9255dc22..9993abb1 100644 --- a/packages/delivery/src/slack.test.ts +++ b/packages/delivery/src/slack.test.ts @@ -24,6 +24,7 @@ test('readSlackReaction normalizes wrapped message reactions', () => { readSlackReaction({ data: { raw_event: { + type: 'reaction_added', item: { type: 'message', channel: 'C12345678__engineering', @@ -36,6 +37,7 @@ test('readSlackReaction normalizes wrapped message reactions', () => { } }), { + action: 'added', channel: 'C12345678', messageTs: '1787300000.000100', actorId: 'U12345678', @@ -49,12 +51,14 @@ test('readSlackReaction accepts event wrappers and rejects unusable reaction ite assert.deepEqual( readSlackReaction({ event: { + type: 'reaction_added', item: { channel: 'D12345678', ts: '1787300000.000100' }, user_id: 'U12345678', reaction: 'eyes' } }), { + action: 'added', channel: 'D12345678', messageTs: '1787300000.000100', actorId: 'U12345678', @@ -63,6 +67,7 @@ test('readSlackReaction accepts event wrappers and rejects unusable reaction ite ); assert.equal( readSlackReaction({ + type: 'reaction_added', item: { type: 'file', channel: 'C12345678', ts: '1787300000.000100' }, user: 'U12345678', reaction: 'white_check_mark' @@ -71,14 +76,31 @@ test('readSlackReaction accepts event wrappers and rejects unusable reaction ite ); assert.equal( readSlackReaction({ + type: 'reaction_added', item: { type: 'message', channel: 'C12345678' }, user: 'U12345678', reaction: 'white_check_mark' }), null ); + assert.deepEqual( + readSlackReaction({ + type: 'reaction_removed', + item: { type: 'message', channel: 'C12345678', ts: '1787300000.000100' }, + user: 'U12345678', + reaction: 'white_check_mark' + }), + { + action: 'removed', + channel: 'C12345678', + messageTs: '1787300000.000100', + actorId: 'U12345678', + emoji: 'white_check_mark' + } + ); assert.equal( readSlackReaction({ + type: 'reaction_added', channel: 'C12345678', ts: '1787300001.000200', user: 'U12345678', @@ -86,6 +108,14 @@ test('readSlackReaction accepts event wrappers and rejects unusable reaction ite }), null ); + assert.equal( + readSlackReaction({ + item: { type: 'message', channel: 'C12345678', ts: '1787300000.000100' }, + user: 'U12345678', + reaction: 'white_check_mark' + }), + null + ); }); test('loadSlackUsers prefers the compact index and excludes bots and Slackbot', async (t) => { diff --git a/packages/delivery/src/slack.ts b/packages/delivery/src/slack.ts index 816e944d..bbae1366 100644 --- a/packages/delivery/src/slack.ts +++ b/packages/delivery/src/slack.ts @@ -20,6 +20,8 @@ export interface SlackInboundMessage { } export interface SlackReaction { + /** Whether Slack added or removed the reaction. */ + action: 'added' | 'removed'; channel: string; messageTs: string; actorId: string; @@ -116,6 +118,12 @@ export function readSlackReaction(payload: unknown): SlackReaction | null { const event = slackAsRecord(data.event) ?? data; const raw = slackAsRecord(event.raw_event) ?? slackAsRecord(data.raw_event) ?? event; const candidates = [raw, event, data, root]; + const eventType = firstSlackString(candidates, 'type') + ?? firstSlackString(candidates, 'event_type'); + const action = slackReactionAction(eventType); + // Approval handlers must never infer an addition from an untyped event: a + // reaction_removed payload otherwise has the same actor/item/emoji fields. + if (!action) return null; const item = firstSlackRecord(candidates, 'item'); const itemType = slackStr(item?.type) ?? firstSlackString(candidates, 'item_type'); if (itemType && itemType !== 'message') return null; @@ -134,6 +142,7 @@ export function readSlackReaction(payload: unknown): SlackReaction | null { if (!emoji) return null; const eventTs = firstSlackString(candidates, 'event_ts'); return { + action, channel: bareSlackChannelId(channel), messageTs, actorId, @@ -142,6 +151,16 @@ export function readSlackReaction(payload: unknown): SlackReaction | null { }; } +function slackReactionAction(value: string | undefined): SlackReaction['action'] | null { + if (value === 'reaction_added' || value === 'reaction.added' || value === 'slack.reaction.added') { + return 'added'; + } + if (value === 'reaction_removed' || value === 'reaction.removed' || value === 'slack.reaction.removed') { + return 'removed'; + } + return null; +} + /** * Return why an inbound Slack message must be ignored, or null to handle it. * From 99c58a4b81fadb816528fd33f8923d3ed8dd0ac0 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Fri, 21 Aug 2026 09:23:53 +0200 Subject: [PATCH 3/4] fix(delivery): bind Slack approvals to app cards --- README.md | 4 +- packages/delivery/CHANGELOG.md | 3 +- packages/delivery/src/slack-approval.test.ts | 56 +++++++++++++++++--- packages/delivery/src/slack-approval.ts | 10 ++++ packages/delivery/src/slack.test.ts | 43 ++++++++++++++- packages/delivery/src/slack.ts | 18 +++++-- 6 files changed, 122 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 88d9c052..190f3e29 100644 --- a/README.md +++ b/README.md @@ -223,10 +223,12 @@ const card = buildSlackApprovalCard({ const reaction = readSlackReaction((await event.expand('full')).data); if (reaction) { - // Fetch exactly reaction.channel + reaction.messageTs before matching. + // Fetch exactly reaction.channel + reaction.messageTs with + // include_all_metadata: true before matching. const approval = matchSlackApprovalReaction(reaction, reactedMessage, { namespace: 'inbox.archive', approverId: ownerSlackId, + appId: slackAppId, validateActionId: isValidThreadId }); if (approval) await archiveThreads(approval.actionIds); diff --git a/packages/delivery/CHANGELOG.md b/packages/delivery/CHANGELOG.md index 6914d007..5d9aae77 100644 --- a/packages/delivery/CHANGELOG.md +++ b/packages/delivery/CHANGELOG.md @@ -10,7 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add normalized Slack reaction parsing and reusable approval-card utilities - that bind bounded hidden action ids to an exact message, approver, and emoji. + that bind bounded hidden action ids to an exact app-authored message, + approver, and emoji. ## [4.1.41] - 2026-08-14 diff --git a/packages/delivery/src/slack-approval.test.ts b/packages/delivery/src/slack-approval.test.ts index 4ba6ae5f..2b7bbdd4 100644 --- a/packages/delivery/src/slack-approval.test.ts +++ b/packages/delivery/src/slack-approval.test.ts @@ -11,6 +11,7 @@ const options = { namespace: 'github-inbox.archive', approverId: 'U12345678' } as const; +const matchOptions = { ...options, appId: 'A12345678' } as const; test('buildSlackApprovalCard keeps exact ids out of rendered text and decodes them', () => { const card = buildSlackApprovalCard({ @@ -58,6 +59,26 @@ test('readSlackApproval recovers from Slack-redacted metadata through hidden blo actionIds: ['thread-1', 'thread-2'] } ); + assert.deepEqual( + readSlackApproval({ ts: '1787300000.000100', metadata: card.metadata }, options), + { + namespace: 'github-inbox.archive', + approverId: 'U12345678', + actionIds: ['thread-1', 'thread-2'] + } + ); + assert.equal( + readSlackApproval({ ts: '1787300000.000100', blocks: card.blocks }, options), + null + ); + assert.equal( + readSlackApproval({ + ts: '1787300000.000100', + metadata: { event_type: 'another_event', event_payload: {} }, + blocks: card.blocks + }, options), + null + ); }); test('matchSlackApprovalReaction binds actor, emoji, and exact message timestamp', () => { @@ -73,23 +94,46 @@ test('matchSlackApprovalReaction binds actor, emoji, and exact message timestamp actorId: 'U12345678', emoji: 'white_check_mark' }; - const message = { ts: reaction.messageTs, ...card }; + const message = { ts: reaction.messageTs, app_id: matchOptions.appId, ...card }; - assert.deepEqual(matchSlackApprovalReaction(reaction, message, options)?.actionIds, ['thread-1']); + assert.deepEqual( + matchSlackApprovalReaction(reaction, message, matchOptions)?.actionIds, + ['thread-1'] + ); + assert.deepEqual( + matchSlackApprovalReaction( + reaction, + { ...message, app_id: undefined, bot_profile: { app_id: matchOptions.appId } }, + matchOptions + )?.actionIds, + ['thread-1'] + ); + assert.equal( + matchSlackApprovalReaction({ ...reaction, actorId: 'U87654321' }, message, matchOptions), + null + ); assert.equal( - matchSlackApprovalReaction({ ...reaction, actorId: 'U87654321' }, message, options), + matchSlackApprovalReaction({ ...reaction, emoji: 'eyes' }, message, matchOptions), null ); assert.equal( - matchSlackApprovalReaction({ ...reaction, emoji: 'eyes' }, message, options), + matchSlackApprovalReaction( + reaction, + { ...message, ts: '1787300002.000300' }, + matchOptions + ), null ); assert.equal( - matchSlackApprovalReaction(reaction, { ...message, ts: '1787300002.000300' }, options), + matchSlackApprovalReaction({ ...reaction, action: 'removed' }, message, matchOptions), null ); assert.equal( - matchSlackApprovalReaction({ ...reaction, action: 'removed' }, message, options), + matchSlackApprovalReaction( + reaction, + { ...message, app_id: 'A87654321' }, + matchOptions + ), null ); }); diff --git a/packages/delivery/src/slack-approval.ts b/packages/delivery/src/slack-approval.ts index 92a56c2c..dac418c3 100644 --- a/packages/delivery/src/slack-approval.ts +++ b/packages/delivery/src/slack-approval.ts @@ -45,6 +45,8 @@ export interface ReadSlackApprovalOptions { export interface MatchSlackApprovalOptions extends ReadSlackApprovalOptions { /** Slack emoji name, with or without surrounding colons. */ emoji?: string; + /** Required Slack app id of the card author, for example `A012345678`. */ + appId: string; } export interface SlackApproval { @@ -133,6 +135,7 @@ export function readSlackApproval( const metadata = asRecord(record.metadata); const eventPayload = asRecord(metadata?.event_payload); const standardMetadata = metadata?.event_type === APPROVAL_METADATA_EVENT; + if (!standardMetadata) return null; const metadataHasIdentity = eventPayload !== null && ( 'namespace' in eventPayload || 'approver_id' in eventPayload ); @@ -210,6 +213,13 @@ export function matchSlackApprovalReaction( if (typeof messageRecord?.ts !== 'string' || messageRecord.ts !== reaction.messageTs) { return null; } + const botProfile = asRecord(messageRecord.bot_profile); + const messageAppId = typeof messageRecord.app_id === 'string' + ? messageRecord.app_id + : typeof botProfile?.app_id === 'string' + ? botProfile.app_id + : undefined; + if (!options.appId.trim() || messageAppId !== options.appId) return null; return readSlackApproval(message, options); } diff --git a/packages/delivery/src/slack.test.ts b/packages/delivery/src/slack.test.ts index 9993abb1..69736398 100644 --- a/packages/delivery/src/slack.test.ts +++ b/packages/delivery/src/slack.test.ts @@ -52,7 +52,7 @@ test('readSlackReaction accepts event wrappers and rejects unusable reaction ite readSlackReaction({ event: { type: 'reaction_added', - item: { channel: 'D12345678', ts: '1787300000.000100' }, + item: { type: 'message', channel: 'D12345678', ts: '1787300000.000100' }, user_id: 'U12345678', reaction: 'eyes' } @@ -98,6 +98,38 @@ test('readSlackReaction accepts event wrappers and rejects unusable reaction ite emoji: 'white_check_mark' } ); + assert.deepEqual( + readSlackReaction({ + type: 'reaction_added', + item_type: 'message', + channel: 'C12345678', + message_ts: '1787300000.000100', + ts: '1787300001.000200', + user: 'U12345678', + reaction: 'white_check_mark' + }), + { + action: 'added', + channel: 'C12345678', + messageTs: '1787300000.000100', + actorId: 'U12345678', + emoji: 'white_check_mark' + } + ); + assert.deepEqual( + readSlackReaction({ + data: { + user: 'U_OUTER', + raw_event: { + type: 'reaction_added', + item: { type: 'message', channel: 'C12345678', ts: '1787300000.000100' }, + user_id: 'U_INNER', + reaction: 'white_check_mark' + } + } + })?.actorId, + 'U_INNER' + ); assert.equal( readSlackReaction({ type: 'reaction_added', @@ -116,6 +148,15 @@ test('readSlackReaction accepts event wrappers and rejects unusable reaction ite }), null ); + assert.equal( + readSlackReaction({ + type: 'reaction_added', + item: { channel: 'C12345678', ts: '1787300000.000100' }, + user: 'U12345678', + reaction: 'white_check_mark' + }), + null + ); }); test('loadSlackUsers prefers the compact index and excludes bots and Slackbot', async (t) => { diff --git a/packages/delivery/src/slack.ts b/packages/delivery/src/slack.ts index bbae1366..9dbab0aa 100644 --- a/packages/delivery/src/slack.ts +++ b/packages/delivery/src/slack.ts @@ -126,15 +126,14 @@ export function readSlackReaction(payload: unknown): SlackReaction | null { if (!action) return null; const item = firstSlackRecord(candidates, 'item'); const itemType = slackStr(item?.type) ?? firstSlackString(candidates, 'item_type'); - if (itemType && itemType !== 'message') return null; + if (itemType !== 'message') return null; const channel = slackStr(item?.channel) ?? firstSlackString(candidates, 'channel'); // Do not fall back to a generic top-level `ts`: on raw reaction events that // can be the reaction event time rather than the reacted message. An item ts // or explicitly named message_ts is required for exact-message binding. const messageTs = slackStr(item?.ts) ?? firstSlackString(candidates, 'message_ts'); - const actorId = firstSlackString(candidates, 'user') - ?? firstSlackString(candidates, 'user_id'); + const actorId = firstSlackStringOfKeys(candidates, ['user', 'user_id']); const reaction = firstSlackString(candidates, 'reaction'); if (!channel || !messageTs || !actorId || !reaction) return null; @@ -413,6 +412,19 @@ function firstSlackString( return undefined; } +function firstSlackStringOfKeys( + records: ReadonlyArray>, + keys: readonly string[] +): string | undefined { + for (const record of records) { + for (const key of keys) { + const value = slackStr(record[key]); + if (value) return value; + } + } + return undefined; +} + function firstSlackRecord( records: ReadonlyArray>, key: string From 4155dd3471cc739e969d822155ab204b2e441272 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Fri, 21 Aug 2026 09:27:47 +0200 Subject: [PATCH 4/4] fix(runtime): bind app triggers to cron events --- packages/runtime/src/app-trigger.test.ts | 13 +++++++++---- packages/runtime/src/app-trigger.ts | 8 +++++++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/app-trigger.test.ts b/packages/runtime/src/app-trigger.test.ts index 78b65158..1626619d 100644 --- a/packages/runtime/src/app-trigger.test.ts +++ b/packages/runtime/src/app-trigger.test.ts @@ -38,13 +38,16 @@ test('readAppTriggerIntent distinguishes scheduled ticks from app triggers', asy assert.deepEqual(await readAppTriggerIntent(event), { kind: 'not-app-trigger' }); }); -test('readAppTriggerIntent does not mistake provider data for an app trigger', async () => { +test('readAppTriggerIntent does not mistake app-trigger-shaped provider data for a trigger', async () => { const event = envelopeToAgentEvent({ id: 'github-1', workspace: 'workspace-1', type: 'github.issue.opened', occurredAt: '2026-08-21T10:00:00Z', - resource: { issue: { number: 42 } } + resource: { + source: 'app.trigger', + payload: { issue: { number: 42 } } + } }); assert.ok(event); @@ -53,8 +56,9 @@ test('readAppTriggerIntent does not mistake provider data for an app trigger', a test('readAppTriggerIntent reports missing and non-object payloads as malformed', async () => { const eventWith = (data: unknown) => ({ + type: 'cron.tick', expand: async () => ({ data }) - }) as unknown as Pick; + }) as unknown as Pick; assert.deepEqual( await readAppTriggerIntent(eventWith({ source: 'app.trigger' })), @@ -72,6 +76,7 @@ test('readAppTriggerIntent reports missing and non-object payloads as malformed' test('readAppTriggerIntent accepts the older nested resource wrapper', async () => { const event = { + type: 'cron.tick', expand: async () => ({ data: { resource: { @@ -80,7 +85,7 @@ test('readAppTriggerIntent accepts the older nested resource wrapper', async () } } }) - } as unknown as Pick; + } as unknown as Pick; assert.deepEqual(await readAppTriggerIntent(event), { kind: 'app-trigger', diff --git a/packages/runtime/src/app-trigger.ts b/packages/runtime/src/app-trigger.ts index 64f28bbd..302b172b 100644 --- a/packages/runtime/src/app-trigger.ts +++ b/packages/runtime/src/app-trigger.ts @@ -25,6 +25,10 @@ export type AppTriggerIntent = /** * Read Cloud's authenticated app-trigger wrapper from a normalized event. * + * Manual triggers are delivered only as `cron.tick` events. Other event types + * fail closed before their provider payload is expanded, even if that payload + * happens to contain an app-trigger-shaped object. + * * Current v4 events expose the original envelope through * `event.expand('full').data`. One nested `resource` wrapper is also accepted * for compatibility with older gateway fixtures. Bare objects are never @@ -38,8 +42,10 @@ export type AppTriggerIntent = * schedule firing. */ export async function readAppTriggerIntent( - event: Pick + event: Pick ): Promise { + if (event.type !== 'cron.tick') return { kind: 'not-app-trigger' }; + let data: unknown; try { data = (await event.expand('full')).data;