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/1544.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A connector a Bot switched off can no longer be reopened by a Workflow. The run Bot a node spawns records who started the run (`createdBy`) and which Bots' switches cap it (`cappedBy`: the caller and the Bot that saved the graph), and the host refuses a connector turned off for any of them — with `turned off for <name>; the step's tools list cannot turn it back on` — and masks it out of the run Bot's list, so naming `github` on a node cannot turn a switch back on. A run started from the board, the canvas or the API has no caller and is capped by the graph's author; a graph saved with no Bot behind it has no ceiling, and an older graph keeps today's behaviour. ([#1544](https://github.com/ThinkFlowLab/FlyBot/issues/1544))
27 changes: 25 additions & 2 deletions core/src/host/connector-access.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,24 @@ export function install(rt) {
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))
// #1544: a run Bot must not reopen a connector a Bot switched off. The record names the Bots whose
// switches cap it — the caller that started the run (createdBy), and the Bot that saved the graph
// (cappedBy), which is the ceiling when the board, the canvas or the API started the run. A Bot that
// no longer exists, and a record with neither field (an older run), leave the answer as it was:
// there is no switch list to intersect with.
const capsOf = (bot) => {
const ids = [bot?.createdBy, ...(Array.isArray(bot?.cappedBy) ? bot.cappedBy : [])]
.filter(id => typeof id === 'string' && id !== '')
const caps = []
for (const id of ids) {
const cap = rt.botOf(id)
if (cap !== undefined && !caps.includes(cap)) caps.push(cap)
}
return caps
}
const capOff = (bot, id) => capsOf(bot).find(cap => turnedOff(cap, id))
const allowed = (bot, id) => bot.remote === undefined && !turnedOff(bot, id)
&& (bot.ephemeral === undefined || (stepGrants(bot, id) && capOff(bot, id) === undefined))

// 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 @@ -126,7 +143,13 @@ 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.`
if (bot.ephemeral !== undefined) {
// #1544: the step did name it — a Bot's switch is what refuses it, and naming it again in the
// graph cannot turn it back on.
const cap = capOff(bot, id)
if (cap !== undefined) return `The ${id} connector is turned off for ${cap.name}; the step's tools list cannot turn it back on.`
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
21 changes: 19 additions & 2 deletions core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,10 +168,26 @@ export function install(rt) {
return tools
}

async function create({ name, systemPrompt, fields, role, instructions, createdBy, plugin = SHARED, hidden, ephemeral, model, tools, permission } = {}) {
// #1544: the Bots whose connector switches cap an ephemeral Bot (a Workflow run names the Bot that
// started it and the Bot that saved the graph). Only the ids are stored; each is read at call time,
// so a switch flipped later applies at once and a Bot that is gone stops capping.
function cleanCappedBy(value) {
if (!Array.isArray(value)) return []
const ids = []
for (const item of value) {
const id = String(item ?? '').trim()
if (id === '' || ids.includes(id)) continue
ids.push(id)
if (ids.length === 8) break
}
return ids
}

async function create({ name, systemPrompt, fields, role, instructions, createdBy, cappedBy, plugin = SHARED, hidden, ephemeral, model, tools, permission } = {}) {
checkName('plugin', plugin)
await rt.load()
const stepTools = ephemeral === true ? cleanStepTools(tools) : []
const caps = ephemeral === true ? cleanCappedBy(cappedBy) : []
const preset = ephemeral === true ? String(permission ?? '').trim() : ''
const bot = await rt.createBot({
name, role, instructions, createdBy,
Expand All @@ -184,6 +200,7 @@ export function install(rt) {
})
const record = rt.botOf(bot.id)
if (record && stepTools.length > 0) record.stepTools = stepTools
if (record && caps.length > 0) record.cappedBy = caps
if (record && preset) {
try {
await rt.setPermission(record, preset)
Expand All @@ -192,7 +209,7 @@ export function install(rt) {
throw error
}
}
if (record && (stepTools.length > 0 || preset)) await rt.save()
if (record && (stepTools.length > 0 || caps.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
37 changes: 37 additions & 0 deletions core/test/connector-access.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -572,3 +572,40 @@ test('a run Bot gets a connector only when its step tools name it', async (t) =>
assert.deepEqual(sorted(hiddenFrom('bare')), sorted([...COMPOSIO, ...GITHUB]), 'no stepTools fails closed')
assert.match(guard(GITHUB[0], 'bare'), /not granted to this run/)
})

// #1544: a Workflow node names the connectors it may call, and since #1535 that names enough to reach
// them. That must not become a way around a switch: a connector turned off for the Bot that started
// the run (or that saved the graph) stays off for the run Bot, and the tool is masked out of its list
// (denied()/restrict()), not only refused when it is called.
test('a run Bot does not reopen a connector the Bot that started the run switched off', async (t) => {
const state = team()
state.bots.writer.connectors = { github: false }
const runBot = (id, { createdBy, cappedBy, stepTools = ['github'] } = {}) => {
state.bots[id] = {
id, sessionId: id, name: id, role: 'Run', instructions: '', createdAt: 4,
hidden: true, ephemeral: { plugin: 'flybot-workflow' }, stepTools,
...(createdBy === undefined ? {} : { createdBy }),
...(cappedBy === undefined ? {} : { cappedBy }),
}
}
runBot('caller-off', { createdBy: 'writer' })
runBot('caller-on', { createdBy: 'chief' })
runBot('caller-gone', { createdBy: 'removed-bot' })
runBot('author-off', { cappedBy: ['writer'] })
runBot('both-on', { createdBy: 'chief', cappedBy: ['chief'] })
const { agentFor, hiddenFrom, guard, stop } = await setup({ state })
t.after(stop)
await Promise.all(['caller-off', 'caller-on', 'caller-gone', 'author-off', 'both-on'].map(id => agentFor(id)))

assert.match(guard(GITHUB[0], 'caller-off'), /turned off for Writer; the step's tools list cannot turn it back on/, 'the switch is the reason, not the step list')
assert.deepEqual(sorted(hiddenFrom('caller-off')), sorted([...COMPOSIO, ...GITHUB]), 'and the tool is out of the run Bot\'s list too')
assert.equal(guard(GITHUB[0], 'caller-on'), undefined, 'a caller that still has github keeps the #1535 answer')
assert.deepEqual(sorted(hiddenFrom('caller-on')), sorted(COMPOSIO))
// A run nobody's tool call started (the board, the canvas, the API) has no caller; the Bot that
// saved the graph is the ceiling then.
assert.match(guard(GITHUB[0], 'author-off'), /turned off for Writer; the step's tools list cannot turn it back on/)
assert.deepEqual(sorted(hiddenFrom('author-off')), sorted([...COMPOSIO, ...GITHUB]))
assert.equal(guard(GITHUB[0], 'both-on'), undefined)
// A Bot that no longer exists has no switch list to read: the answer stays what it was.
assert.equal(guard(GITHUB[0], 'caller-gone'), undefined)
})
2 changes: 1 addition & 1 deletion docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,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. 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`. **It is not a team member either (#1445):** a node's `tools` covers connectors and the shell, not the team surface, so `EPHEMERAL_DENIED_TOOLS` in `tools.js` names every tool whose effect reaches past the run Bot's own Session — `message_bot`/`stop_bot`/`create_bot`/`update_bot`, groups and posts, `ask_user`, the capability writes, `request_secret`/`list_secrets`, the schedule writes, `send_message`, the social and workflow tools, `read_browser`, `recall` (its default scope reads the team memory) and `create_child_bot`. `connector-access.js` refuses each call with `A running Bot is not a team member.`, `approvals.js` answers `rejected` for the same names (also for a subagent child Session, by following the parent chain through `rt.whoseAgent`), and the run Bot's own list loses them through `restrict({ deny })`; a name the profile serves but the mask cannot enforce stays listed with a warn line, and the guard is what refuses it. `EPHEMERAL_ALLOWED_TOOLS` is the rest: the read-only tools (`list_bots`, `list_models`, `list_capabilities`, `bot_schedules`, `schedule_list`, `read_own_chat`, `read_group_chat`) and `remember`/`forget`, which `writeDenied` keeps inside the run Bot's own scope. Ordinary Bots, friend's Bots and group rooms are unchanged, and `plugins/workflow/**` is not edited: the node's `tools` still decides connectors and the shell. Covered by `core/test/flybot.test.js`, `core/test/connector-access.test.js` and `core/test/run-bot-tools.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 run Bot is also capped by the Bots whose connector switches apply to it (#1544): `createdBy` names the Bot whose tool call started the run, `cappedBy` names every Bot whose switches cap it (the caller and the Bot that saved the workflow graph), and `connector-access.js` refuses a connector turned off for any of them with `turned off for <name>; the step's tools list cannot turn it back on`, dropping it from the run Bot's own list as well. A Bot that no longer exists, and a run recorded before this with neither field, leaves the answer as it was: there is no switch list to intersect with. A save with no Bot behind it (the canvas, the API, a template copy) records no author, which is the owner's own edit and lifts that ceiling again. 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`. **It is not a team member either (#1445):** a node's `tools` covers connectors and the shell, not the team surface, so `EPHEMERAL_DENIED_TOOLS` in `tools.js` names every tool whose effect reaches past the run Bot's own Session — `message_bot`/`stop_bot`/`create_bot`/`update_bot`, groups and posts, `ask_user`, the capability writes, `request_secret`/`list_secrets`, the schedule writes, `send_message`, the social and workflow tools, `read_browser`, `recall` (its default scope reads the team memory) and `create_child_bot`. `connector-access.js` refuses each call with `A running Bot is not a team member.`, `approvals.js` answers `rejected` for the same names (also for a subagent child Session, by following the parent chain through `rt.whoseAgent`), and the run Bot's own list loses them through `restrict({ deny })`; a name the profile serves but the mask cannot enforce stays listed with a warn line, and the guard is what refuses it. `EPHEMERAL_ALLOWED_TOOLS` is the rest: the read-only tools (`list_bots`, `list_models`, `list_capabilities`, `bot_schedules`, `schedule_list`, `read_own_chat`, `read_group_chat`) and `remember`/`forget`, which `writeDenied` keeps inside the run Bot's own scope. Ordinary Bots, friend's Bots and group rooms are unchanged, and `plugins/workflow/**` is not edited: the node's `tools` still decides connectors and the shell. Covered by `core/test/flybot.test.js`, `core/test/connector-access.test.js` and `core/test/run-bot-tools.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
2 changes: 1 addition & 1 deletion docs/subsystems/plugin-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots

### Run Bots (#1373)

`flybot.bot.create({ ephemeral: true })` makes a run Bot for the calling plugin. The host stores `hidden: true` and `ephemeral: { plugin }` before the first save. The marker is set only there: `fields.set` cannot add it, and a spec cannot name another plugin. `bot.list`, `bot.find`, `flybot/bots` and `bot.created` omit it. `bot.get` still returns it (`hidden`, `systemPrompt`). `bot.remove(id)` deletes it only when `ephemeral.plugin` is the caller; any other Bot throws, and a missing id returns `false`. `CreateBotSpec` also takes `hidden`, `model`, and — for a run Bot only — `tools` and `permission`. `tools` is stored as `stepTools` (`github` grants `mcp__github__*`). `permission` is a DSH preset (`workspace-write` or `danger-full-access`) applied to that Bot's Session; a failed apply deletes the Bot. A team Bot create ignores both. A run Bot has no visible chat, so an approval card would stall it: a granted tool is allowed, a connector it did not name is refused, and a shell escalation to full access is allowed only when `bash`, `shell` or `danger-full-access` is in `stepTools`. Covered by `core/test/flybot.test.js`.
`flybot.bot.create({ ephemeral: true })` makes a run Bot for the calling plugin. The host stores `hidden: true` and `ephemeral: { plugin }` before the first save. The marker is set only there: `fields.set` cannot add it, and a spec cannot name another plugin. `bot.list`, `bot.find`, `flybot/bots` and `bot.created` omit it. `bot.get` still returns it (`hidden`, `systemPrompt`). `bot.remove(id)` deletes it only when `ephemeral.plugin` is the caller; any other Bot throws, and a missing id returns `false`. `CreateBotSpec` also takes `hidden`, `model`, and — for a run Bot only — `tools`, `permission`, `createdBy` and `cappedBy`. `createdBy` is the Bot whose tool call started the run; `cappedBy` lists the Bots whose connector switches cap it (the caller and the Bot that saved the workflow graph), and the host reads each id at call time, so a switch flipped later applies at once and a Bot that is gone stops capping (#1544). `tools` is stored as `stepTools` (`github` grants `mcp__github__*`). `permission` is a DSH preset (`workspace-write` or `danger-full-access`) applied to that Bot's Session; a failed apply deletes the Bot. A team Bot create ignores both. A run Bot has no visible chat, so an approval card would stall it: a granted tool is allowed, a connector it did not name is refused, and a shell escalation to full access is allowed only when `bash`, `shell` or `danger-full-access` is in `stepTools`. Covered by `core/test/flybot.test.js`.

### Chat-row pending badge

Expand Down
Loading
Loading