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/1402.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A Workflow run now waits for each node's Bot to reply before the next node starts, and that reply is the chat line. A second task queues instead of replacing the run that is already going. Replay demo stays a separate script and does not move a live run; after a live run finishes, the stage keeps that run until Replay steps the script, and from then on the graph follows the demo rather than the finished run. Each node can name its inputs and outputs, and a required input with no upstream provider refuses the start. A dispatch that does not land stays unhanded, is named in the chat, and is sent again on the next hand-off. An older saved demo still replays, and a task waiting on the host cap starts when any run finishes, ahead of a newer assign. A 429 mentioned in a successful reply does not fail the step. A loop edge is the only cycle: it stops at max (1–8, default 3) or when its until output matches, and a when edge chooses a branch while the other is skipped. The next pass of a loop can take the other branch. Each node is an ephemeral Bot for that run, removed when the run ends, and its tools grant GitHub or a shell instead of a permissionless hidden Bot. ([#1402](https://github.com/ThinkFlowLab/FlyBot/issues/1402))
21 changes: 21 additions & 0 deletions core/src/host/approvals.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,25 @@ const OUTCOMES = { allow: 'allowed-once', reject: 'rejected', 'always-bot': 'all
// DSH has no standing grant (its outcomes are one-shot, #540), so the owner's "Always
// allow" lives here: on the Bot or on the group, keyed by the kind of permission asked.
// A sandbox escalation names its mode in DSH's reason; any other ask is keyed by tool.
// A run Bot has no chat the owner can see, so an approval card would stall the
// step. Connector tools are allowed only when the node's stepTools name them
// (`github` covers `mcp__github__*`). A shell escalation to full access needs
// `bash`, `shell` or `danger-full-access`. Other calls follow the Bot's preset
// and are answered here, because nobody can tap the card.
export function ephemeralToolVerdict(bot, toolName, reason) {
if (!bot?.ephemeral) return undefined
const tools = new Set((bot.stepTools ?? []).map(name => String(name).toLowerCase()))
const grant = (name) => tools.has('*') || tools.has(String(name).toLowerCase())
const tool = String(toolName ?? '')
if (tool.startsWith('mcp__')) {
const id = tool.split('__')[1] ?? ''
return grant(tool) || (id !== '' && grant(id)) ? 'allowed-once' : 'rejected'
}
const mode = /^escalate sandbox to ([\w-]+):/.exec(String(reason ?? ''))?.[1]
if (mode === 'danger-full-access') return grant('bash') || grant('shell') || grant('danger-full-access') ? 'allowed-once' : 'rejected'
return 'allowed-once'
}

export function approvalKind(tool, reason) {
const mode = /^escalate sandbox to ([\w-]+):/.exec(String(reason ?? ''))?.[1]
return mode ? `${tool}:escalate:${mode}` : String(tool)
Expand All @@ -30,6 +49,8 @@ export function install(rt) {
const owner = sessionId === undefined ? undefined : rt.groupOwners.get(sessionId)
const botId = owner?.botId ?? (sessionId === undefined ? undefined : rt.chatOwners.get(sessionId))
if (botId === undefined) return next()
const step = ephemeralToolVerdict(rt.botOf(botId), String(request.toolName ?? ''), request.reason)
if (step !== undefined) return step
if (standingCovers(sessionId, botId, owner?.roomId, approvalKind(String(request.toolName ?? ''), request.reason))) return 'allowed-once'
const id = randomUUID()
const entry = {
Expand Down
18 changes: 14 additions & 4 deletions core/src/host/connector-access.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
// parent's Bot.
// - The owner can turn a connector off for one Bot: `bot.connectors = { composio: false }`. Absent
// means on, so every Bot, old or new, has the connected connector.
// - A friend's Bot (`remote`) never gets them, and neither does a Session whose Bot or parent chain
// cannot be resolved (fail closed). Sessions that belong to no Bot (the DSH Agent, plain DSH
// sessions) are not touched: they are the owner's own, and no friend can reach them.
// - A friend's Bot (`remote`) never gets them. A run Bot (`ephemeral`) gets a connector
// only when its `stepTools` name it (`github` covers `mcp__github__*`). A Session whose
// Bot or parent chain cannot be resolved fails closed. Sessions that belong to no Bot
// (the DSH Agent, plain DSH sessions) are not touched: they are the owner's own, and no
// friend can reach them.
// - A group room turn carries neither the composio tools nor the four schedule tools (#1237): 8
// tools, ~20.4k characters (≈5.2k tokens at the measured 3.92 chars/token) re-sent on every call
// of every room turn, and a room round uses neither — an outside app is reached from the Bot's own
Expand Down Expand Up @@ -42,7 +44,14 @@ export function install(rt) {
// Only an explicit false turns it off; a Bot with no record has it (a team saved by #346, where an
// absent record meant off, therefore starts on).
const turnedOff = (bot, id) => bot?.connectors?.[id] === false
const allowed = (bot, id) => bot.remote === undefined && !turnedOff(bot, id)
// A run Bot is not a team member. It gets a connector only when the step
// named it (`github` → mcp__github__*), so a node that did not ask cannot
// open pull requests.
const stepGrants = (bot, id) => {
const tools = bot?.stepTools ?? []
return tools.includes(id) || tools.includes('*') || tools.some(name => name === id || String(name).startsWith(`mcp__${id}__`))
}
const allowed = (bot, id) => bot.remote === undefined && !turnedOff(bot, id) && (bot.ephemeral === undefined || stepGrants(bot, id))

// The Bot an Agent works for: its own chat, a group Session and an earlier part resolve through
// selfOf; a sub-agent Session is followed up to its parent. The answer has three shapes, and only
Expand Down Expand Up @@ -109,6 +118,7 @@ export function install(rt) {
const { bot } = who
if (allowed(bot, id)) return undefined
if (bot.remote !== undefined) return `The ${id} connector is not available to a friend's Bot.`
if (bot.ephemeral !== undefined) return `The ${id} connector is not granted to this run. The step's tools list is what allows it.`
return `The ${id} connector is turned off for ${bot.name}. Its owner can turn it on in this Bot's settings.`
})

Expand Down
28 changes: 27 additions & 1 deletion core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,24 @@ export function install(rt) {
})
const persist = () => rt.save().catch(error => warn('flybot: save failed: %s', error))

async function create({ name, systemPrompt, fields, role, instructions, createdBy, plugin = SHARED, hidden, ephemeral, model } = {}) {
const TOOL_NAME = /^[a-z][a-z0-9_-]{0,63}$/
function cleanStepTools(value) {
if (!Array.isArray(value)) return []
const tools = []
for (const item of value) {
const name = String(item ?? '').trim().toLowerCase()
if (!TOOL_NAME.test(name) || tools.includes(name)) continue
tools.push(name)
if (tools.length === 16) break
}
return tools
}

async function create({ name, systemPrompt, fields, role, instructions, createdBy, plugin = SHARED, hidden, ephemeral, model, tools, permission } = {}) {
checkName('plugin', plugin)
await rt.load()
const stepTools = ephemeral === true ? cleanStepTools(tools) : []
const preset = ephemeral === true ? String(permission ?? '').trim() : ''
const bot = await rt.createBot({
name, role, instructions, createdBy,
...(systemPrompt ? { systemPrompt } : {}),
Expand All @@ -166,6 +181,17 @@ export function install(rt) {
// The marker names this plugin. A caller cannot stamp another plugin's name.
...(ephemeral === true ? { ephemeral: { plugin } } : {}),
})
const record = rt.botOf(bot.id)
if (record && stepTools.length > 0) record.stepTools = stepTools
if (record && preset) {
try {
await rt.setPermission(record, preset)
} catch (error) {
await rt.deleteBot(bot.id).catch(error => warn('flybot: could not remove the Bot after a permission failure: %s', error))
throw error
}
}
if (record && (stepTools.length > 0 || preset)) await rt.save()
for (const [key, value] of Object.entries(fields ?? {})) fire('change', { botId: bot.id, plugin, key, value: clone(value), prev: undefined })
return bot.id
}
Expand Down
28 changes: 28 additions & 0 deletions core/test/connector-access.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -544,3 +544,31 @@ test('a Bot post carries whose request it is into the room\'s member turns and i
assert.deepEqual(await run(TYPED).then(({ post, member, report }) => ({ post, member, report })), { post: 'owner', member: 'owner', report: 'owner' })
assert.deepEqual(await run(FRIEND).then(({ post, member, report }) => ({ post, member, report })), { post: 'other', member: 'other', report: 'other' }, 'a friend-driven poster makes the whole round someone else\'s')
})

test('a run Bot gets a connector only when its step tools name it', async (t) => {
const state = team()
const run = (id, name, stepTools) => {
state.bots[id] = {
id, sessionId: id, name, role: 'Run', instructions: '', createdAt: 4,
hidden: true, ephemeral: { plugin: 'flybot-workflow' },
...(stepTools ? { stepTools } : {}),
}
}
run('coder', 'Coder', ['github'])
run('shell', 'Shell', ['bash'])
run('star', 'Star', ['*'])
run('bare', 'Bare')
const { agentFor, hiddenFrom, guard, stop } = await setup({ state })
t.after(stop)
for (const id of ['coder', 'shell', 'star', 'bare']) await agentFor(id)
assert.deepEqual(sorted(hiddenFrom('coder')), sorted(COMPOSIO), 'github stays listed')
assert.equal(guard(GITHUB[0], 'coder'), undefined)
assert.match(guard(COMPOSIO[0], 'coder'), /not granted to this run/)
assert.deepEqual(sorted(hiddenFrom('shell')), sorted([...COMPOSIO, ...GITHUB]))
assert.match(guard(GITHUB[0], 'shell'), /not granted to this run/)
assert.deepEqual([...hiddenFrom('star')], [])
assert.equal(guard(GITHUB[0], 'star'), undefined)
assert.equal(guard(COMPOSIO[0], 'star'), undefined)
assert.deepEqual(sorted(hiddenFrom('bare')), sorted([...COMPOSIO, ...GITHUB]), 'no stepTools fails closed')
assert.match(guard(GITHUB[0], 'bare'), /not granted to this run/)
})
56 changes: 54 additions & 2 deletions core/test/flybot.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@ async function start(config = {}) {
const home = mkdtempSync(join(tmpdir(), 'ds-bot-flybot-'))
const state = config.empty === true ? emptyTeam() : team()
Object.assign(state.rooms, config.rooms ?? {})
const { rooms, empty, llm, defaultModel, ...applyConfig } = config
const { rooms, empty, llm, defaultModel, services: extraServices, ...applyConfig } = config
writeFileSync(join(home, 'state.json'), JSON.stringify(state))
// A test may serve a model: the profile default rides the DSH service, as in test/memory.test.js.
const services = defaultModel === undefined ? {} : { agentDefaultModel: { currentSelection: () => ({ provider: 'deepseek', model: defaultModel }) } }
const services = { ...(extraServices ?? {}) }
if (defaultModel !== undefined) services.agentDefaultModel = { currentSelection: () => ({ provider: 'deepseek', model: defaultModel }) }
const ctx = fakeContext({ services })
const agents = new Map()
const agentOf = (id) => {
Expand Down Expand Up @@ -203,6 +204,57 @@ test('an ephemeral Bot stays off the team and only its plugin can remove it', as
assert.equal(await wf.bot.remove(id), false)
})

test('a run Bot is granted the step tools and is not left waiting on a hidden approval', async (t) => {
const writes = []
const PRESETS = { 'workspace-write': {}, 'danger-full-access': {} }
const { fb, ctx, home, stop } = await start({
services: {
permissionPresets: {
catalog: () => ({ options: Object.keys(PRESETS).map(value => ({ value, name: value })), defaultOptions: [], defaultPreset: 'workspace-write' }),
resolve: (name) => { if (!(name in PRESETS)) throw new Error(`permission: unknown preset "${name}"`); return PRESETS[name] },
set: (session, name) => { writes.push(`${session?.id ?? session}=${name}`) },
},
},
})
t.after(stop)
let sessions = 0
ctx.sessionController.create = async () => ({ sessionId: `run${++sessions}` })
const wf = fb.scope('flybot-workflow')
const id = await wf.bot.create({
name: 'Coder ab',
role: 'Code',
instructions: 'Open the pull request.',
ephemeral: true,
tools: ['GitHub', 'read'],
permission: 'workspace-write',
})
const saved = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8'))
const bot = saved.bots[id]
assert.equal(bot.permission, 'workspace-write')
assert.deepEqual(bot.stepTools, ['github', 'read'])
assert.deepEqual(writes, [`${id}=workspace-write`])
const ask = (toolName, reason = '') => ctx.listeners.get('approval/request')[0](
{ agent: { session: { id } }, toolName, reason },
() => 'from-dsh',
)
assert.equal(await ask('mcp__github__create_pull_request'), 'allowed-once')
assert.equal(await ask('mcp__composio__execute'), 'rejected')
assert.equal(await ask('bash', 'escalate sandbox to danger-full-access: gh pr create'), 'rejected')
assert.equal(await ask('read'), 'allowed-once')
const shell = await wf.bot.create({
name: 'Shell ab',
ephemeral: true,
tools: ['bash'],
permission: 'danger-full-access',
})
const shellAsk = (toolName, reason = '') => ctx.listeners.get('approval/request')[0](
{ agent: { session: { id: shell } }, toolName, reason },
() => 'from-dsh',
)
assert.equal(await shellAsk('bash', 'escalate sandbox to danger-full-access: gh pr create'), 'allowed-once')
assert.equal(await shellAsk('mcp__github__create_pull_request'), 'rejected')
})

test('with no middleware, message_bot behaves as before', async (t) => {
const { tool, received, stop } = await start()
t.after(stop)
Expand Down
11 changes: 11 additions & 0 deletions core/types/flybot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,17 @@ export interface CreateBotSpec {
ephemeral?: boolean
/** Model ref (`provider/model` or a model id). Empty leaves the usual default. */
model?: string
/**
* Tools this run Bot may use. Only read when `ephemeral` is true.
* `github` grants `mcp__github__*`. `bash` or `shell` grants a full-access shell.
* A team Bot ignores this.
*/
tools?: string[]
/**
* DSH permission preset for a run Bot (`workspace-write` or `danger-full-access`).
* Only applied when `ephemeral` is true. A team Bot ignores this.
*/
permission?: string
}

/** What a send middleware sees. Built-in ds-bot sends arrive with via: 'ds-bot'. */
Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi
- New Bot model (#410): `roster.js` `createBot` fixes a Bot's model at creation. A Bot made by another Bot (`create_bot`, a tree child via `flybot.bot.create` with `createdBy`) and given no model starts on its maker's effective model (`desiredModel`) and its thinking level resolved against that model (dropped when that model does not serve it), so its first request never goes to an unconfigured profile default. A model the caller names wins; a Bot the user makes in the UI keeps the client's start model (Main Bot's, #230). Covered by `core/test/roster.test.js`.
- Silent creation (#645): `createBot` and `create-room` send nothing by themselves. A Bot's model is applied without a turn, and a new group posts no opening round, so making a team costs no model turn. A line the user typed in the new-Bot card is their first request: `new-chat.js` sends it to the new Bot as one user turn and the card carries no `brief`; `roster.js` turns a `brief` into `Why you exist: …` instructions only when a Bot created the Bot (`create_bot`, a tree child), where the brief is a parent's context. The `kickoff` role, the `[Bot team setup]` text and the group-created cue are gone (an older transcript's kickoff line is still skipped by `historyEntry`), and `create_bot` answers that the new Bot starts no turn. Covered by `core/test/roster.test.js`, `core/test/rounds.test.js` and `core/test/new-chat.test.js`.
- Every new Bot is announced (`bot.created`) through the `rt.createBot` wrapper, including the first Main Bot `bootstrap` creates on the first `state` request and a `duplicate-bot` copy. Creating those through the module-local `createBot` used to skip the event, so a plugin that adopts new Bots (the tree root) could miss the first Chief or leave a copy outside the tree until a restart. A run Bot is the exception: `createBot` stores `hidden` and `ephemeral: { plugin }` before the first save, and the wrapper does not fire `bot.created` for it. Covered by `core/test/flybot.test.js`.
- A run Bot (#1373) is left out of the channel `state` view, so the sidebar, search and Settings → Bots never list it. `list_bots`, `message_bot`, `stop_bot`, `update_bot` and group member lookup skip it too. `findBot` still sees the name, so a later Bot cannot reuse it. Covered by `core/test/flybot.test.js`.
- A run Bot (#1373) is left out of the channel `state` view, so the sidebar, search and Settings → Bots never list it. `list_bots`, `message_bot`, `stop_bot`, `update_bot` and group member lookup skip it too. `findBot` still sees the name, so a later Bot cannot reuse it. It is not a permissionless shell: `flybot.bot.create` may pass `tools` and `permission` for an ephemeral Bot only. `stepTools` is what connector-access and the approval listener read. `github` grants `mcp__github__*`; a connector the step did not name is hidden and a call is refused (`not granted to this run`). A hidden run Bot cannot show an approval card, so a granted call is `allowed-once` and an ungranted connector or an ungranted full-access shell escalation is `rejected`. Covered by `core/test/flybot.test.js` and `core/test/connector-access.test.js`.
- Group Session models (#289, #1127): `applyModel` does not trust `appliedModel` while a Bot is `modelStale`. A failed model selection in a group Session (from `applyModel` or from `room-participant.js` `open()`) marks the Bot stale; the next apply (model-list change, restart, or the Bot's next turn in a known group Session) selects every Session again and clears the mark once all took it. A settled record (`appliedModel` matches and `modelStale` is unset) still re-selects a group Session whose last request used another provider or model, on that apply and before the Session's next turn; the Bot's own chat is left as recorded. A Session whose route cannot be read is left alone, so a settled Bot is not selected again on every refresh. A fresh part takes the Bot's model, not the previous request's route. Covered by `core/test/models.test.js`, `core/test/room-participant.test.js` and `core/test/fresh-part-model.test.js`.

### Face-to-face room lead (#476)
Expand Down
Loading
Loading