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/1555.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A run Bot started through a friend's Bot no longer reaches a connector: the two Workflow tools have no `visibleTo`, so a remote caller could start a run whose node `tools` alone decided what it reached, and one that started a run is now read as "every connector switched off" — the guard refuses with `… not available to a run started by a friend's Bot`, and the tool mask takes the connectors out of the run Bot's list. An in-team caller whose own switch for a connector is on, and whose node names it, is unchanged, and a run with no caller (the owner, the board) keeps the answer it had. ([#1555](https://github.com/ThinkFlowLab/FlyBot/issues/1555))
23 changes: 21 additions & 2 deletions core/src/host/connector-access.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
// - 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. A run Bot (`ephemeral`) gets a connector
// only when its `stepTools` name it (`github` covers `mcp__github__*`). A Session whose
// only when its `stepTools` name it (`github` covers `mcp__github__*`), and never when a
// friend's Bot is the one that started the run (#1555). 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.
Expand Down Expand Up @@ -69,8 +70,17 @@ export function install(rt) {
return caps
}
const capOff = (bot, id) => capsOf(bot).find(cap => turnedOff(cap, id))
// #1555: `run_workflow`/`define_workflow` have no `visibleTo`, so a friend's Bot can call them and a run
// Bot can name it as the one that started the run (`createdBy`). A remote Bot has no switch table of ours
// to intersect with, so it reads as "every connector switched off": the node's `tools` cannot hand the run
// Bot a connector. A record with no `createdBy` (the owner, the board, an older run) keeps the answer it
// had, and a `createdBy` that names a Bot we do not have leaves it as it was too.
const remoteCaller = (bot) => {
const id = bot?.createdBy
return (typeof id === 'string' && id !== '' ? rt.botOf(id) : undefined)?.remote !== undefined
}
const allowed = (bot, id) => bot.remote === undefined && !turnedOff(bot, id)
&& (bot.ephemeral === undefined || (stepGrants(bot, id) && capOff(bot, id) === undefined))
&& (bot.ephemeral === undefined || (stepGrants(bot, id) && capOff(bot, id) === undefined && !remoteCaller(bot)))

// 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 @@ -144,6 +154,15 @@ export function install(rt) {
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) {
// #1555: the caller is the reason, not the step list — a friend's Bot started this run, and a remote
// Bot has no switch table of ours to intersect with, so it reads as every connector switched off.
if (remoteCaller(bot)) return `The ${id} connector is not available to a run started by a friend's Bot.`
// #1543: a run Bot's own switch is the honest reason when the step did name the connector. The
// settings sentence below is wrong for it — a run Bot is hidden, so its owner has no page to
// flip the switch on — and the tools list cannot either; say both instead of blaming the graph.
if (turnedOff(bot, id)) {
return `The ${id} connector is turned off for ${bot.name}. A step's tools list can narrow what a run may use, but it cannot turn a connector back on.`
}
// #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)
Expand Down
50 changes: 50 additions & 0 deletions core/test/connector-access.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -609,3 +609,53 @@ test('a run Bot does not reopen a connector the Bot that started the run switche
// 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)
})

// #1555: `run_workflow` / `define_workflow` have no `visibleTo`, so a friend's Bot can call them. A run
// Bot then names that remote Bot as the one that started it, and a remote Bot has no switch table of ours
// to intersect with — so the caller reads as "every connector switched off", and the step list cannot hand
// the run Bot anything. Nothing changes for a team Bot: its own switch and the node's `tools` still decide.
test('a run Bot started by a friend\'s Bot reaches no connector', async (t) => {
const state = team()
state.bots.friend = { id: 'friend', sessionId: 'friend', name: 'Bob', role: 'Bot', instructions: '', createdAt: 5, remote: { handle: '@bob.home' } }
const run = (id, { createdBy, stepTools = ['github'] } = {}) => {
state.bots[id] = {
id, sessionId: id, name: id, role: 'Run', instructions: '', createdAt: 6,
hidden: true, ephemeral: { plugin: 'flybot-workflow' }, stepTools,
...(createdBy === undefined ? {} : { createdBy }),
}
}
run('remote-run', { createdBy: 'friend' })
run('team-run', { createdBy: 'chief' })
const { agentFor, hiddenFrom, guard, stop } = await setup({ state })
t.after(stop)
await Promise.all(['remote-run', 'team-run'].map(id => agentFor(id)))

// Red before this change: the node names github, so the step list alone let the run Bot through.
assert.match(guard(GITHUB[0], 'remote-run'), /started by a friend's Bot/, 'the caller is the reason, not the step list')
assert.deepEqual(sorted(hiddenFrom('remote-run')), sorted([...COMPOSIO, ...GITHUB]), 'and both connectors are out of its list too')
assert.match(guard(COMPOSIO[0], 'remote-run'), /started by a friend's Bot/)
// The no-over-refusal case: a team Bot started this run, its switch is on, the node names github.
assert.equal(guard(GITHUB[0], 'team-run'), undefined, 'a team caller keeps what the node names')
assert.deepEqual(sorted(hiddenFrom('team-run')), sorted(COMPOSIO), 'only the connector the node did not name stays out')
})

// #1543: when a run Bot's own connector switch is off, the refusal must name that switch. The step's
// tools list did name the connector, so blaming it sends the reader to fix the graph instead of the
// toggle. The sentence also has to say the tools list cannot turn it back on — a run Bot is
// hidden: true and the owner cannot find it on the settings page to flip the switch there.
test('a run Bot names the switch that turned a connector off, not the step tools', async (t) => {
const state = team()
state.bots.switched = {
id: 'switched', sessionId: 'switched', name: 'Coder Off', role: 'Run', instructions: '', createdAt: 4,
hidden: true, ephemeral: { plugin: 'flybot-workflow' }, stepTools: ['github'], connectors: { github: false },
}
const { agentFor, guard, stop } = await setup({ state })
t.after(stop)
await agentFor('switched')
const reason = guard(GITHUB[0], 'switched')
assert.ok(reason, 'the call is still refused: this fixes the reason, not the decision')
assert.match(reason, /The github connector is turned off for Coder Off\./, 'the Bot whose switch is off is named')
assert.match(reason, /A step's tools list can narrow what a run may use, but it cannot turn a connector back on\./)
assert.doesNotMatch(reason, /not granted to this run/, 'the step tools did name it, so that is not the reason')
assert.doesNotMatch(reason, /settings/, 'a run Bot is hidden from the settings page, so do not send the reader there')
})
3 changes: 2 additions & 1 deletion core/test/workflow-node-connectors.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ test('a node Bot still keeps full file/shell rights and the team tools stay refu
ctx.emit('tools/change')
await new Promise(resolve => setImmediate(resolve))
await new Promise(resolve => setImmediate(resolve))
assert.match(guardFor(id, GITHUB[0]), /not granted to this run/)
assert.match(guardFor(id, GITHUB[0]), /The github connector is turned off for Runner ab\./)
assert.match(guardFor(id, GITHUB[0]), /cannot turn a connector back on/)
assert.deepEqual(listedNow(), [], 'both connectors are hidden once the Bot switch is off')
})
1 change: 1 addition & 0 deletions docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,7 @@ The connector tools are mounted once for the whole DSH profile, so which Bot may
**Decision (the owner, 2026-10-22, #601: 「放开,主要应该用连接器工具」; it supersedes #369's "off by default for other people's use"; one exception, #1237, is the group room's composio and schedule tools, below).** Every turn of every one of the owner's own Bots may use the connected connectors, whatever started it: the owner, a group round of any origin (also a room with a friend or another person in it), a Bot-to-Bot relay, a scheduled turn, a friend talking to a shared Bot, a turn with no recorded origin. The connector tools no longer read `turn-origin.js`.

- **Per-Bot record** (only what differs from the default is stored): `bot.connectors = { github?: false, composio?: false }` is an explicit off for that Bot (absent = on; a legacy `true` from #346 reads as on). `bot-connectors { botId, connector, enabled }` writes it; a `scope` an older client still sends is ignored. The retired "also when others use it" offer (`bot.connectorsShared`) means nothing any more and is dropped on the Bot's next switch or on a disconnect.
- **A run Bot started by a friend's Bot reaches no connector (#1555).** `run_workflow` and `define_workflow` have no `visibleTo`, so a friend's Bot can call them; the run Bot's `createdBy` then names that remote Bot, which has no switch table of ours to intersect with. The ephemeral branch reads that as "every connector switched off": `allowed()` requires the step tools to name the connector **and** a caller that is not remote, so the guard refuses with `The github connector is not available to a run started by a friend's Bot.` and the same `denied()` takes the connectors out of the run Bot's tool list. A run with no `createdBy` (the owner, the board, an older run) and a `createdBy` that names a Bot the team no longer has keep the answer they had, and an in-team caller whose own switch is on, whose node names the connector, still gets it. `visibleTo` on the two tools stays backlog.
- **Boundaries that stay.** A friend's Bot (`bot.remote`) never gets the tools. An Agent whose Bot cannot be determined (a parent Session that is gone, or a parent chain deeper than eight) gets none either (fail closed), so an unresolved sub-agent never reads as a plain owner Session. A chain that really ends at a Session of no Bot (the DSH Agent, a plain Session) keeps them. A sub-agent Session is followed to its parent Bot (`whoseAgent`, also on `rt.whoseAgent` for `secrets.js`).
- **Enforcement is two layers.** A `ctx.tools.guard` refuses every `mcp__github__*` / `mcp__composio__*` call that is not allowed, and `agent.ctx.tools.restrict({ deny })` takes the same tools out of that Agent's tool list. The mask is refreshed when the Agent is created, on every `resolveAgent` (delivery), when a switch changes, and on `tools/change` (a connector that mounts or goes away later). Turns and sharing no longer change it. DSH's hidden "Tool added/removed" notices for these tools are not drawn in the chat (`cells.js`).
- **A mask that cannot be rebuilt keeps the previous one (#1271).** `hideConnectorTools` lifts the old mask before it reads `schemas()` again, so a `restrict()` that refuses a name would otherwise leave the Agent with no mask and every tool listed again. The rebuild now names only what the global view (`schemas()` with no Agent) can restrict, and if a restriction still fails the catch puts the previous deny list back: the guard refuses a call either way, and a list that widens by itself is a wrong answer. A denied name the Agent itself serves stays listed — `restrict()` cannot name a scope's own tool — and the warn line says so.
Expand Down
23 changes: 21 additions & 2 deletions patches/ds-bot/files/src/host/connector-access.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
// - 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. A run Bot (`ephemeral`) gets a connector
// only when its `stepTools` name it (`github` covers `mcp__github__*`). A Session whose
// only when its `stepTools` name it (`github` covers `mcp__github__*`), and never when a
// friend's Bot is the one that started the run (#1555). 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.
Expand Down Expand Up @@ -69,8 +70,17 @@ export function install(rt) {
return caps
}
const capOff = (bot, id) => capsOf(bot).find(cap => turnedOff(cap, id))
// #1555: `run_workflow`/`define_workflow` have no `visibleTo`, so a friend's Bot can call them and a run
// Bot can name it as the one that started the run (`createdBy`). A remote Bot has no switch table of ours
// to intersect with, so it reads as "every connector switched off": the node's `tools` cannot hand the run
// Bot a connector. A record with no `createdBy` (the owner, the board, an older run) keeps the answer it
// had, and a `createdBy` that names a Bot we do not have leaves it as it was too.
const remoteCaller = (bot) => {
const id = bot?.createdBy
return (typeof id === 'string' && id !== '' ? rt.botOf(id) : undefined)?.remote !== undefined
}
const allowed = (bot, id) => bot.remote === undefined && !turnedOff(bot, id)
&& (bot.ephemeral === undefined || (stepGrants(bot, id) && capOff(bot, id) === undefined))
&& (bot.ephemeral === undefined || (stepGrants(bot, id) && capOff(bot, id) === undefined && !remoteCaller(bot)))

// 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 @@ -144,6 +154,15 @@ export function install(rt) {
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) {
// #1555: the caller is the reason, not the step list — a friend's Bot started this run, and a remote
// Bot has no switch table of ours to intersect with, so it reads as every connector switched off.
if (remoteCaller(bot)) return `The ${id} connector is not available to a run started by a friend's Bot.`
// #1543: a run Bot's own switch is the honest reason when the step did name the connector. The
// settings sentence below is wrong for it — a run Bot is hidden, so its owner has no page to
// flip the switch on — and the tools list cannot either; say both instead of blaming the graph.
if (turnedOff(bot, id)) {
return `The ${id} connector is turned off for ${bot.name}. A step's tools list can narrow what a run may use, but it cannot turn a connector back on.`
}
// #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)
Expand Down
50 changes: 50 additions & 0 deletions patches/ds-bot/files/test/connector-access.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -609,3 +609,53 @@ test('a run Bot does not reopen a connector the Bot that started the run switche
// 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)
})

// #1555: `run_workflow` / `define_workflow` have no `visibleTo`, so a friend's Bot can call them. A run
// Bot then names that remote Bot as the one that started it, and a remote Bot has no switch table of ours
// to intersect with — so the caller reads as "every connector switched off", and the step list cannot hand
// the run Bot anything. Nothing changes for a team Bot: its own switch and the node's `tools` still decide.
test('a run Bot started by a friend\'s Bot reaches no connector', async (t) => {
const state = team()
state.bots.friend = { id: 'friend', sessionId: 'friend', name: 'Bob', role: 'Bot', instructions: '', createdAt: 5, remote: { handle: '@bob.home' } }
const run = (id, { createdBy, stepTools = ['github'] } = {}) => {
state.bots[id] = {
id, sessionId: id, name: id, role: 'Run', instructions: '', createdAt: 6,
hidden: true, ephemeral: { plugin: 'flybot-workflow' }, stepTools,
...(createdBy === undefined ? {} : { createdBy }),
}
}
run('remote-run', { createdBy: 'friend' })
run('team-run', { createdBy: 'chief' })
const { agentFor, hiddenFrom, guard, stop } = await setup({ state })
t.after(stop)
await Promise.all(['remote-run', 'team-run'].map(id => agentFor(id)))

// Red before this change: the node names github, so the step list alone let the run Bot through.
assert.match(guard(GITHUB[0], 'remote-run'), /started by a friend's Bot/, 'the caller is the reason, not the step list')
assert.deepEqual(sorted(hiddenFrom('remote-run')), sorted([...COMPOSIO, ...GITHUB]), 'and both connectors are out of its list too')
assert.match(guard(COMPOSIO[0], 'remote-run'), /started by a friend's Bot/)
// The no-over-refusal case: a team Bot started this run, its switch is on, the node names github.
assert.equal(guard(GITHUB[0], 'team-run'), undefined, 'a team caller keeps what the node names')
assert.deepEqual(sorted(hiddenFrom('team-run')), sorted(COMPOSIO), 'only the connector the node did not name stays out')
})

// #1543: when a run Bot's own connector switch is off, the refusal must name that switch. The step's
// tools list did name the connector, so blaming it sends the reader to fix the graph instead of the
// toggle. The sentence also has to say the tools list cannot turn it back on — a run Bot is
// hidden: true and the owner cannot find it on the settings page to flip the switch there.
test('a run Bot names the switch that turned a connector off, not the step tools', async (t) => {
const state = team()
state.bots.switched = {
id: 'switched', sessionId: 'switched', name: 'Coder Off', role: 'Run', instructions: '', createdAt: 4,
hidden: true, ephemeral: { plugin: 'flybot-workflow' }, stepTools: ['github'], connectors: { github: false },
}
const { agentFor, guard, stop } = await setup({ state })
t.after(stop)
await agentFor('switched')
const reason = guard(GITHUB[0], 'switched')
assert.ok(reason, 'the call is still refused: this fixes the reason, not the decision')
assert.match(reason, /The github connector is turned off for Coder Off\./, 'the Bot whose switch is off is named')
assert.match(reason, /A step's tools list can narrow what a run may use, but it cannot turn a connector back on\./)
assert.doesNotMatch(reason, /not granted to this run/, 'the step tools did name it, so that is not the reason')
assert.doesNotMatch(reason, /settings/, 'a run Bot is hidden from the settings page, so do not send the reader there')
})
3 changes: 2 additions & 1 deletion patches/ds-bot/files/test/workflow-node-connectors.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ test('a node Bot still keeps full file/shell rights and the team tools stay refu
ctx.emit('tools/change')
await new Promise(resolve => setImmediate(resolve))
await new Promise(resolve => setImmediate(resolve))
assert.match(guardFor(id, GITHUB[0]), /not granted to this run/)
assert.match(guardFor(id, GITHUB[0]), /The github connector is turned off for Runner ab\./)
assert.match(guardFor(id, GITHUB[0]), /cannot turn a connector back on/)
assert.deepEqual(listedNow(), [], 'both connectors are hidden once the Bot switch is off')
})
Loading