Skip to content

Commit 040de20

Browse files
committed
fix(workflow): a run started by a remote Bot gets no connectors (#1555) (replay of #1558)
1 parent ac7a916 commit 040de20

6 files changed

Lines changed: 90 additions & 4 deletions

File tree

‎changelog.d/1555.fixed.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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))

‎core/src/host/connector-access.js‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
// - The owner can turn a connector off for one Bot: `bot.connectors = { composio: false }`. Absent
1010
// means on, so every Bot, old or new, has the connected connector.
1111
// - A friend's Bot (`remote`) never gets them. A run Bot (`ephemeral`) gets a connector
12-
// only when its `stepTools` name it (`github` covers `mcp__github__*`). A Session whose
12+
// only when its `stepTools` name it (`github` covers `mcp__github__*`), and never when a
13+
// friend's Bot is the one that started the run (#1555). A Session whose
1314
// Bot or parent chain cannot be resolved fails closed. Sessions that belong to no Bot
1415
// (the DSH Agent, plain DSH sessions) are not touched: they are the owner's own, and no
1516
// friend can reach them.
@@ -69,8 +70,17 @@ export function install(rt) {
6970
return caps
7071
}
7172
const capOff = (bot, id) => capsOf(bot).find(cap => turnedOff(cap, id))
73+
// #1555: `run_workflow`/`define_workflow` have no `visibleTo`, so a friend's Bot can call them and a run
74+
// Bot can name it as the one that started the run (`createdBy`). A remote Bot has no switch table of ours
75+
// to intersect with, so it reads as "every connector switched off": the node's `tools` cannot hand the run
76+
// Bot a connector. A record with no `createdBy` (the owner, the board, an older run) keeps the answer it
77+
// had, and a `createdBy` that names a Bot we do not have leaves it as it was too.
78+
const remoteCaller = (bot) => {
79+
const id = bot?.createdBy
80+
return (typeof id === 'string' && id !== '' ? rt.botOf(id) : undefined)?.remote !== undefined
81+
}
7282
const allowed = (bot, id) => bot.remote === undefined && !turnedOff(bot, id)
73-
&& (bot.ephemeral === undefined || (stepGrants(bot, id) && capOff(bot, id) === undefined))
83+
&& (bot.ephemeral === undefined || (stepGrants(bot, id) && capOff(bot, id) === undefined && !remoteCaller(bot)))
7484

7585
// The Bot an Agent works for: its own chat, a group Session and an earlier part resolve through
7686
// selfOf; a sub-agent Session is followed up to its parent. The answer has three shapes, and only
@@ -144,6 +154,9 @@ export function install(rt) {
144154
if (allowed(bot, id)) return undefined
145155
if (bot.remote !== undefined) return `The ${id} connector is not available to a friend's Bot.`
146156
if (bot.ephemeral !== undefined) {
157+
// #1555: the caller is the reason, not the step list — a friend's Bot started this run, and a remote
158+
// Bot has no switch table of ours to intersect with, so it reads as every connector switched off.
159+
if (remoteCaller(bot)) return `The ${id} connector is not available to a run started by a friend's Bot.`
147160
// #1544: the step did name it — a Bot's switch is what refuses it, and naming it again in the
148161
// graph cannot turn it back on.
149162
const cap = capOff(bot, id)

‎core/test/connector-access.test.js‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,3 +609,32 @@ test('a run Bot does not reopen a connector the Bot that started the run switche
609609
// A Bot that no longer exists has no switch list to read: the answer stays what it was.
610610
assert.equal(guard(GITHUB[0], 'caller-gone'), undefined)
611611
})
612+
613+
// #1555: `run_workflow` / `define_workflow` have no `visibleTo`, so a friend's Bot can call them. A run
614+
// Bot then names that remote Bot as the one that started it, and a remote Bot has no switch table of ours
615+
// to intersect with — so the caller reads as "every connector switched off", and the step list cannot hand
616+
// the run Bot anything. Nothing changes for a team Bot: its own switch and the node's `tools` still decide.
617+
test('a run Bot started by a friend\'s Bot reaches no connector', async (t) => {
618+
const state = team()
619+
state.bots.friend = { id: 'friend', sessionId: 'friend', name: 'Bob', role: 'Bot', instructions: '', createdAt: 5, remote: { handle: '@bob.home' } }
620+
const run = (id, { createdBy, stepTools = ['github'] } = {}) => {
621+
state.bots[id] = {
622+
id, sessionId: id, name: id, role: 'Run', instructions: '', createdAt: 6,
623+
hidden: true, ephemeral: { plugin: 'flybot-workflow' }, stepTools,
624+
...(createdBy === undefined ? {} : { createdBy }),
625+
}
626+
}
627+
run('remote-run', { createdBy: 'friend' })
628+
run('team-run', { createdBy: 'chief' })
629+
const { agentFor, hiddenFrom, guard, stop } = await setup({ state })
630+
t.after(stop)
631+
await Promise.all(['remote-run', 'team-run'].map(id => agentFor(id)))
632+
633+
// Red before this change: the node names github, so the step list alone let the run Bot through.
634+
assert.match(guard(GITHUB[0], 'remote-run'), /started by a friend's Bot/, 'the caller is the reason, not the step list')
635+
assert.deepEqual(sorted(hiddenFrom('remote-run')), sorted([...COMPOSIO, ...GITHUB]), 'and both connectors are out of its list too')
636+
assert.match(guard(COMPOSIO[0], 'remote-run'), /started by a friend's Bot/)
637+
// The no-over-refusal case: a team Bot started this run, its switch is on, the node names github.
638+
assert.equal(guard(GITHUB[0], 'team-run'), undefined, 'a team caller keeps what the node names')
639+
assert.deepEqual(sorted(hiddenFrom('team-run')), sorted(COMPOSIO), 'only the connector the node did not name stays out')
640+
})

‎docs/subsystems/core-host.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,7 @@ The connector tools are mounted once for the whole DSH profile, so which Bot may
440440
**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`.
441441

442442
- **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.
443+
- **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.
443444
- **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`).
444445
- **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`).
445446
- **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.

‎patches/ds-bot/files/src/host/connector-access.js‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
// - The owner can turn a connector off for one Bot: `bot.connectors = { composio: false }`. Absent
1010
// means on, so every Bot, old or new, has the connected connector.
1111
// - A friend's Bot (`remote`) never gets them. A run Bot (`ephemeral`) gets a connector
12-
// only when its `stepTools` name it (`github` covers `mcp__github__*`). A Session whose
12+
// only when its `stepTools` name it (`github` covers `mcp__github__*`), and never when a
13+
// friend's Bot is the one that started the run (#1555). A Session whose
1314
// Bot or parent chain cannot be resolved fails closed. Sessions that belong to no Bot
1415
// (the DSH Agent, plain DSH sessions) are not touched: they are the owner's own, and no
1516
// friend can reach them.
@@ -69,8 +70,17 @@ export function install(rt) {
6970
return caps
7071
}
7172
const capOff = (bot, id) => capsOf(bot).find(cap => turnedOff(cap, id))
73+
// #1555: `run_workflow`/`define_workflow` have no `visibleTo`, so a friend's Bot can call them and a run
74+
// Bot can name it as the one that started the run (`createdBy`). A remote Bot has no switch table of ours
75+
// to intersect with, so it reads as "every connector switched off": the node's `tools` cannot hand the run
76+
// Bot a connector. A record with no `createdBy` (the owner, the board, an older run) keeps the answer it
77+
// had, and a `createdBy` that names a Bot we do not have leaves it as it was too.
78+
const remoteCaller = (bot) => {
79+
const id = bot?.createdBy
80+
return (typeof id === 'string' && id !== '' ? rt.botOf(id) : undefined)?.remote !== undefined
81+
}
7282
const allowed = (bot, id) => bot.remote === undefined && !turnedOff(bot, id)
73-
&& (bot.ephemeral === undefined || (stepGrants(bot, id) && capOff(bot, id) === undefined))
83+
&& (bot.ephemeral === undefined || (stepGrants(bot, id) && capOff(bot, id) === undefined && !remoteCaller(bot)))
7484

7585
// The Bot an Agent works for: its own chat, a group Session and an earlier part resolve through
7686
// selfOf; a sub-agent Session is followed up to its parent. The answer has three shapes, and only
@@ -144,6 +154,9 @@ export function install(rt) {
144154
if (allowed(bot, id)) return undefined
145155
if (bot.remote !== undefined) return `The ${id} connector is not available to a friend's Bot.`
146156
if (bot.ephemeral !== undefined) {
157+
// #1555: the caller is the reason, not the step list — a friend's Bot started this run, and a remote
158+
// Bot has no switch table of ours to intersect with, so it reads as every connector switched off.
159+
if (remoteCaller(bot)) return `The ${id} connector is not available to a run started by a friend's Bot.`
147160
// #1544: the step did name it — a Bot's switch is what refuses it, and naming it again in the
148161
// graph cannot turn it back on.
149162
const cap = capOff(bot, id)

‎patches/ds-bot/files/test/connector-access.test.js‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,3 +609,32 @@ test('a run Bot does not reopen a connector the Bot that started the run switche
609609
// A Bot that no longer exists has no switch list to read: the answer stays what it was.
610610
assert.equal(guard(GITHUB[0], 'caller-gone'), undefined)
611611
})
612+
613+
// #1555: `run_workflow` / `define_workflow` have no `visibleTo`, so a friend's Bot can call them. A run
614+
// Bot then names that remote Bot as the one that started it, and a remote Bot has no switch table of ours
615+
// to intersect with — so the caller reads as "every connector switched off", and the step list cannot hand
616+
// the run Bot anything. Nothing changes for a team Bot: its own switch and the node's `tools` still decide.
617+
test('a run Bot started by a friend\'s Bot reaches no connector', async (t) => {
618+
const state = team()
619+
state.bots.friend = { id: 'friend', sessionId: 'friend', name: 'Bob', role: 'Bot', instructions: '', createdAt: 5, remote: { handle: '@bob.home' } }
620+
const run = (id, { createdBy, stepTools = ['github'] } = {}) => {
621+
state.bots[id] = {
622+
id, sessionId: id, name: id, role: 'Run', instructions: '', createdAt: 6,
623+
hidden: true, ephemeral: { plugin: 'flybot-workflow' }, stepTools,
624+
...(createdBy === undefined ? {} : { createdBy }),
625+
}
626+
}
627+
run('remote-run', { createdBy: 'friend' })
628+
run('team-run', { createdBy: 'chief' })
629+
const { agentFor, hiddenFrom, guard, stop } = await setup({ state })
630+
t.after(stop)
631+
await Promise.all(['remote-run', 'team-run'].map(id => agentFor(id)))
632+
633+
// Red before this change: the node names github, so the step list alone let the run Bot through.
634+
assert.match(guard(GITHUB[0], 'remote-run'), /started by a friend's Bot/, 'the caller is the reason, not the step list')
635+
assert.deepEqual(sorted(hiddenFrom('remote-run')), sorted([...COMPOSIO, ...GITHUB]), 'and both connectors are out of its list too')
636+
assert.match(guard(COMPOSIO[0], 'remote-run'), /started by a friend's Bot/)
637+
// The no-over-refusal case: a team Bot started this run, its switch is on, the node names github.
638+
assert.equal(guard(GITHUB[0], 'team-run'), undefined, 'a team caller keeps what the node names')
639+
assert.deepEqual(sorted(hiddenFrom('team-run')), sorted(COMPOSIO), 'only the connector the node did not name stays out')
640+
})

0 commit comments

Comments
 (0)