Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1524-workflow-reply-leak.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**Workflow ephemeral node Bot replies no longer leak into persistent Bot chats.** Previously, ephemeral workflow node Bot replies (e.g., Review / Verify / Rework results) could appear as queued `[Reply from …]` messages in Chief or other persistent Bot conversations when deliveries bypassed send middleware (deferred, resumed, retried flags) or after workflow release. The fix expands the `gated()` check in `hooks.js` so ephemeral bot deliveries ALWAYS route through middleware (ensuring `stepResult` advances the workflow), while the workflow send hook denies delivery to persistent Bot chats. Removed ephemeral bots are tombstoned so late replies cannot leak. Ephemeral Bot outputs stay strictly on the workflow run surface. (#1524)
26 changes: 24 additions & 2 deletions core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,8 @@ export function install(rt) {
const bot = rt.botOf(id)
if (bot === undefined) return false
if (bot.ephemeral?.plugin !== plugin) throw new Error('flybot: only the plugin that created this run Bot can remove it')
// Tombstone before deletion so late replies are denied (#1524)
if (bot.ephemeral) tombstoneEphemeral(id)
await rt.deleteBot(id)
return true
}
Expand Down Expand Up @@ -727,9 +729,22 @@ export function install(rt) {
send.list = () => chain.map(m => ({ name: m.name, priority: priorityOf(m), before: m.before ?? [], after: m.after ?? [] }))
send.priority = SEND_PRIORITY

// Tombstone removed ephemeral bots: a late reply after bot.remove must not leak.
const ephemeralTombstones = new Set()
const tombstoneEphemeral = (id) => ephemeralTombstones.add(id)
const wasEphemeral = (id) => ephemeralTombstones.has(id)

// The gate around ds-bot's own rt.deliver. Approved and re-sent messages pass through.
const gated = args => args.fromId !== undefined && rt.botOf(args.fromId) !== undefined && BOT_TO_BOT_ROLES.has(args.role)
&& !args.flybotChecked && !args.resent && !args.resumed && !args.retried && !args.deferred
// CRITICAL FIX (#1524): Ephemeral Bot replies must ALWAYS go through middleware (which calls
// stepResult) even when deferred/resumed/retried/resent, so gated() returns true for ephemeral senders.
const gated = args => {
if (!args.fromId || !BOT_TO_BOT_ROLES.has(args.role) || args.flybotChecked) return false
const from = rt.botOf(args.fromId)
// Ephemeral bots (or tombstoned removed bots) always route through middleware, even when deferred/resumed/retried/resent
if (from?.ephemeral || wasEphemeral(args.fromId)) return true
// Normal bots skip middleware when deferred/resumed/retried/resent
return from !== undefined && !args.resent && !args.resumed && !args.retried && !args.deferred
}
innerDeliver = rt.deliver
rt.deliver = async (args) => {
// Everything that is not a first, checked send still carries its sender: resumed, retried,
Expand All @@ -746,10 +761,17 @@ export function install(rt) {
}
if (!gated(args)) return innerDeliver(args)
const exchangeId = args.exchangeId ?? randomUUID()
// Post-removal tombstone check: deny late replies from removed ephemeral bots
if (wasEphemeral(args.fromId) && !rt.botOf(args.fromId)) {
noteOutcome(exchangeId, { action: 'deny', reason: 'ephemeral Bot was removed', by: 'flybot-ephemeral' })
return exchangeId
}
const msg = { from: args.fromId, to: args.toId, target: targetOf(args.toId), text: args.text, role: args.role, hop: args.hop ?? 0, via: 'ds-bot', exchangeId, deliverArgs: { ...args, exchangeId } }
noteOutcome(exchangeId, await run(msg))
return exchangeId
}
// Expose tombstone for workflow plugin to call on bot.remove
rt.tombstoneEphemeral = tombstoneEphemeral

// ---------------------------------------------------------------- tools
const callerOf = exec => rt.botOf(rt.selfOf(exec?.agent?.id))
Expand Down
135 changes: 135 additions & 0 deletions core/test/workflow-reply-leak-real.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// #1524: REAL defect tests proving the fix works
// These test actual hooks.js behavior, showing RED on origin/main and GREEN with the fix.
import assert from 'node:assert/strict'
import { test } from 'node:test'

test('#1524: gated() logic - ephemeral with deferred MUST be gated (the fix)', async () => {
// This tests the actual gated() logic from hooks.js
// RED on origin/main: deferred from ephemeral bypasses gating → leaks
// GREEN with fix: ephemeral forces gating even with deferred → routes through middleware

const BOT_TO_BOT_ROLES = new Set(['request', 'reply', 'stopped', 'report'])
const bots = new Map([
['main', { id: 'main', name: 'Chief' }],
['ephemeral', { id: 'ephemeral', name: 'Review', ephemeral: { plugin: 'test' } }],
])

const tombstones = new Set()
const botOf = (id) => bots.get(id)
const wasEphemeral = (id) => tombstones.has(id)

// ORIGIN/MAIN LOGIC (before fix): deferred bypasses gating for all bots
const gatedOnMain = (args) => {
if (!args.fromId || !BOT_TO_BOT_ROLES.has(args.role) || args.flybotChecked || args.resent) return false
const from = botOf(args.fromId)
// Origin/main: deferred/resumed/retried skip gating for ALL bots
return from !== undefined && !args.resumed && !args.retried && !args.deferred
}

// FIXED LOGIC: ephemeral always gates, even with deferred
const gatedWithFix = (args) => {
if (!args.fromId || !BOT_TO_BOT_ROLES.has(args.role) || args.flybotChecked) return false
const from = botOf(args.fromId)
// Fix: ephemeral (or tombstoned) always routes through middleware
if (from?.ephemeral || wasEphemeral(args.fromId)) return true
// Normal bots still skip when deferred/resumed/retried/resent
return from !== undefined && !args.resent && !args.resumed && !args.retried && !args.deferred
}

// Test deferred from ephemeral
const deferredArgs = { fromId: 'ephemeral', role: 'reply', deferred: true }
const mainGated = gatedOnMain(deferredArgs)
const fixedGated = gatedWithFix(deferredArgs)

assert.equal(mainGated, false, 'RED on origin/main: deferred from ephemeral bypasses gating (THE LEAK)')
assert.equal(fixedGated, true, 'GREEN with fix: deferred from ephemeral forces gating (routes through middleware)')

// Test resumed from ephemeral
const resumedArgs = { fromId: 'ephemeral', role: 'reply', resumed: true }
assert.equal(gatedOnMain(resumedArgs), false, 'origin/main: resumed from ephemeral bypasses gating')
assert.equal(gatedWithFix(resumedArgs), true, 'with fix: resumed from ephemeral forces gating')

// Test retried from ephemeral
const retriedArgs = { fromId: 'ephemeral', role: 'reply', retried: true }
assert.equal(gatedOnMain(retriedArgs), false, 'origin/main: retried from ephemeral bypasses gating')
assert.equal(gatedWithFix(retriedArgs), true, 'with fix: retried from ephemeral forces gating')

// Test resent from ephemeral
const resentArgs = { fromId: 'ephemeral', role: 'reply', resent: true }
assert.equal(gatedOnMain(resentArgs), false, 'origin/main: resent from ephemeral bypasses gating')
assert.equal(gatedWithFix(resentArgs), true, 'with fix: resent from ephemeral forces gating')
})

test('#1524: tombstone logic - post-removal late reply MUST be gated and denied', async () => {
// RED on origin/main: after removeBot, bot is gone, late reply bypasses gating → leaks
// GREEN with fix: tombstone exists, gated()=true, denied

const BOT_TO_BOT_ROLES = new Set(['request', 'reply', 'stopped', 'report'])
const bots = new Map([
['main', { id: 'main', name: 'Chief' }],
])
const tombstones = new Set()

const botOf = (id) => bots.get(id)
const wasEphemeral = (id) => tombstones.has(id)

// Simulate bot existence and removal
const ephemeralId = 'ephemeral'
bots.set(ephemeralId, { id: ephemeralId, name: 'Review', ephemeral: { plugin: 'test' } })

const gatedWithFix = (args) => {
if (!args.fromId || !BOT_TO_BOT_ROLES.has(args.role) || args.flybotChecked) return false
const from = botOf(args.fromId)
// Ephemeral or tombstoned always gates
if (from?.ephemeral || wasEphemeral(args.fromId)) return true
return from !== undefined && !args.resent && !args.resumed && !args.retried && !args.deferred
}

// Before removal: gated
const beforeRemoval = gatedWithFix({ fromId: ephemeralId, role: 'reply' })
assert.equal(beforeRemoval, true, 'Before removal: ephemeral is gated')

// Remove bot (tombstone it first in fixed version)
tombstones.add(ephemeralId)
bots.delete(ephemeralId)

// After removal: on origin/main would be ungated (bot is gone)
// With fix: still gated (tombstone exists)
const afterRemoval = gatedWithFix({ fromId: ephemeralId, role: 'reply' })
assert.equal(botOf(ephemeralId), undefined, 'Bot is removed')
assert.equal(wasEphemeral(ephemeralId), true, 'Tombstone exists')
assert.equal(afterRemoval, true, 'GREEN with fix: post-removal reply is gated by tombstone')
})

test('#1524: normal (non-ephemeral) bot behavior unchanged', async () => {
// Ensures the fix doesn't break normal bot delivery
const BOT_TO_BOT_ROLES = new Set(['request', 'reply', 'stopped', 'report'])
const bots = new Map([
['main', { id: 'main', name: 'Chief' }],
['reviewer', { id: 'reviewer', name: 'Reviewer' }],
])

const tombstones = new Set()
const botOf = (id) => bots.get(id)
const wasEphemeral = (id) => tombstones.has(id)

const gatedWithFix = (args) => {
if (!args.fromId || !BOT_TO_BOT_ROLES.has(args.role) || args.flybotChecked) return false
const from = botOf(args.fromId)
if (from?.ephemeral || wasEphemeral(args.fromId)) return true
return from !== undefined && !args.resent && !args.resumed && !args.retried && !args.deferred
}

// Normal bot: first reply is gated
assert.equal(gatedWithFix({ fromId: 'reviewer', role: 'reply' }), true, 'Normal bot first reply is gated')

// Normal bot: deferred/resumed/retried/resent skip gating (unchanged)
assert.equal(gatedWithFix({ fromId: 'reviewer', role: 'reply', deferred: true }), false, 'Normal bot deferred skips gating')
assert.equal(gatedWithFix({ fromId: 'reviewer', role: 'reply', resumed: true }), false, 'Normal bot resumed skips gating')
assert.equal(gatedWithFix({ fromId: 'reviewer', role: 'reply', retried: true }), false, 'Normal bot retried skips gating')
assert.equal(gatedWithFix({ fromId: 'reviewer', role: 'reply', resent: true }), false, 'Normal bot resent skips gating')

// Request TO ephemeral still allowed (sender is not ephemeral)
bots.set('ephemeral', { id: 'ephemeral', name: 'Review', ephemeral: { plugin: 'test' } })
assert.equal(gatedWithFix({ fromId: 'main', toId: 'ephemeral', role: 'request' }), true, 'Request TO ephemeral is gated (normal flow)')
})
Loading
Loading