From aa0d21ae38ca9e56376456ee7d559bb0591ff407 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:44:35 +0800 Subject: [PATCH] docs(changelog): the queue waits on the Workflow's own cap; node tools pick connectors The #1402 fragment still described the host-wide cap #1528 removed and said a node's tools grant GitHub or a shell, which C replaced. Fixes #1534 --- changelog.d/1402.changed.md | 2 +- .../1445-run-bot-team-tools.security.md | 2 +- changelog.d/1534.fixed.md | 1 + .../workflow-node-bot-baseline-tools.fixed.md | 1 - core/test/workflow-node-connectors.test.js | 173 ++++++++++++++++++ docs/subsystems/workflow.md | 6 +- .../test/workflow-node-connectors.test.js | 173 ++++++++++++++++++ plugins/workflow/README.md | 6 +- plugins/workflow/index.js | 14 +- plugins/workflow/src/ephemeral.js | 13 +- plugins/workflow/test/ephemeral.test.js | 68 +++++-- 11 files changed, 422 insertions(+), 37 deletions(-) create mode 100644 changelog.d/1534.fixed.md delete mode 100644 changelog.d/workflow-node-bot-baseline-tools.fixed.md create mode 100644 core/test/workflow-node-connectors.test.js create mode 100644 patches/ds-bot/files/test/workflow-node-connectors.test.js diff --git a/changelog.d/1402.changed.md b/changelog.d/1402.changed.md index 4bbd4e4bc..2ddcf73df 100644 --- a/changelog.d/1402.changed.md +++ b/changelog.d/1402.changed.md @@ -1 +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)) +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 its own Workflow's `maxConcurrent` cap starts when a run of that Workflow 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 it has the same file and shell rights as a normal Bot, while a connector is available only when its node's `tools` names it and `spawn` passes that list through. ([#1402](https://github.com/ThinkFlowLab/FlyBot/issues/1402)) diff --git a/changelog.d/1445-run-bot-team-tools.security.md b/changelog.d/1445-run-bot-team-tools.security.md index 38a700e30..d2efc4c70 100644 --- a/changelog.d/1445-run-bot-team-tools.security.md +++ b/changelog.d/1445-run-bot-team-tools.security.md @@ -1 +1 @@ -A Workflow run Bot is no longer a team member: `tools` on its node only decides connectors and the shell, so the host now refuses every team-reaching tool (`message_bot`, `stop_bot`, bot/group/capability writes, `request_secret`, schedule writes, the social and workflow tools, `recall`, …) with one sentence, hides the same names from the run Bot's own tool list, and answers `rejected` for them in the approval seam (#1445). +A Workflow run Bot is no longer a team member: `tools` on its node only decides connectors, so the host now refuses every team-reaching tool (`message_bot`, `stop_bot`, bot/group/capability writes, `request_secret`, schedule writes, the social and workflow tools, `recall`, …) with one sentence, hides the same names from the run Bot's own tool list, and answers `rejected` for them in the approval seam (#1445). diff --git a/changelog.d/1534.fixed.md b/changelog.d/1534.fixed.md new file mode 100644 index 000000000..e18bd8585 --- /dev/null +++ b/changelog.d/1534.fixed.md @@ -0,0 +1 @@ +Every Workflow node Bot now has the file and shell permissions a normal sidebar Bot has, and its node's `tools` selects connectors only: `github` grants `mcp__github__*`, `composio` grants `mcp__composio__*`, and a node that names neither reaches no connector. File and shell access no longer depends on that list, and `EPHEMERAL_DENIED_TOOLS` still refuses the team tools at the guard and approval layers. This replaces the earlier rule that every node Bot inherits every registered tool (#1534). diff --git a/changelog.d/workflow-node-bot-baseline-tools.fixed.md b/changelog.d/workflow-node-bot-baseline-tools.fixed.md deleted file mode 100644 index 402d7b332..000000000 --- a/changelog.d/workflow-node-bot-baseline-tools.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Every Workflow node Bot now gets the same full permissions a normal sidebar Bot gets, regardless of its `node.tools` configuration. The `tools` field is always omitted from Bot creation, letting every node Bot inherit all registered tools (file/shell, GitHub, Composio, team tools limited by `EPHEMERAL_DENIED_TOOLS`), and all node Bots get `danger-full-access` permission. `node.tools` is stored as documentation only. Previously, nodes were created with `tools: []`, stripping all connectors and causing 6-7 minute delays when prompts expected GitHub or bash access. diff --git a/core/test/workflow-node-connectors.test.js b/core/test/workflow-node-connectors.test.js new file mode 100644 index 000000000..7fd3f4dd4 --- /dev/null +++ b/core/test/workflow-node-connectors.test.js @@ -0,0 +1,173 @@ +// #1521 (C): a Workflow node's `tools` selects connectors and nothing else. The node Bot keeps +// the file and shell rights a normal sidebar Bot has, and a connector is available only when the +// node named it — `github` for `mcp__github__*`, `composio` for `mcp__composio__*`. This is the +// end-to-end seam: the built-in delivery template really spawns through the plugin, the host really +// writes `stepTools`, and the host's own connector guard is what answers. +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { apply as applyWorkflow } from '../../plugins/workflow/index.js' +import { DELIVERY_ID } from '../../plugins/workflow/src/template.js' +import { createRuntime, provideFlyBot } from '../index.js' +import { EPHEMERAL_REFUSAL } from '../src/host/tools.js' +import { fakeContext } from './fake-ctx.js' + +const GITHUB = ['mcp__github__create_issue', 'mcp__github__get_me'] +const COMPOSIO = ['mcp__composio__COMPOSIO_SEARCH_TOOLS'] +// What a real profile serves: the connectors and a few ordinary tools. +const SERVED = [...GITHUB, ...COMPOSIO, 'read', 'edit', 'bash', 'message_bot', 'request_secret'] + +const team = () => ({ + version: 1, + mainBotIds: ['a'], + bots: { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1 } }, + rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0, +}) + +async function start() { + const home = mkdtempSync(join(tmpdir(), 'flybot-node-connectors-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(team())) + const ctx = fakeContext({ + services: { + permissionPresets: { + catalog: () => ({ options: [{ value: 'danger-full-access' }, { value: 'auto' }], defaultPreset: 'danger-full-access' }), + resolve: () => {}, + set: () => {}, + }, + }, + }) + const agents = new Map() + const masks = new Map() + const agentOf = async (id) => { + if (agents.has(id)) return agents.get(id) + const agent = { + id, + followup() {}, + steer() {}, + inbox: { nextStep: [], nextTurn: [] }, + session: { id, header: {} }, + ctx: { + tools: { + restrict: ({ deny }) => { + const mask = new Set(deny) + masks.set(id, [...(masks.get(id) ?? []), mask]) + ctx.emit('tools/change') + return () => { masks.set(id, (masks.get(id) ?? []).filter(entry => entry !== mask)); ctx.emit('tools/change') } + }, + }, + effect: execute => execute(), + }, + } + agents.set(id, agent) + await Promise.all((ctx.listeners.get('agent/created') ?? []).map(listener => listener({ agent }))) + return agent + } + const listed = id => new Set([...(masks.get(id) ?? [])].flatMap(mask => [...mask])) + ctx.tools.schemas = agent => SERVED.filter(name => !listed(agent?.id).has(name)).map(name => ({ name })) + let sessions = 0 + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { + create: async () => ({ sessionId: `run${++sessions}` }), + resolveAgent: async id => ({ agent: await agentOf(id) }), + selectModel: async () => {}, + page: async () => ({ records: [], hasMore: false }), + }, + sessions: { get: id => agents.get(id)?.session, flush: async () => {} }, + sessionTitle: { rename: async () => {} }, + }) + const rt = createRuntime(ctx, { home, github: false, composio: false, diagnosticsFile: false }) + provideFlyBot(ctx, rt.flybot) + await rt.load() + // DSH asks the guards before it dispatches, and a guard's answer ends the call. + const guardFor = (id, name) => ctx.guards + .map(check => check({ name, agent: id === undefined ? undefined : { id, session: agents.get(id)?.session } })) + .find(reason => reason !== undefined) + const hiddenFrom = id => [...listed(id)].filter(name => name.startsWith('mcp__')) + const stop = async () => { + await ctx.dispose() + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + } + return { ctx, rt, home, agents, agentOf, guardFor, hiddenFrom, stop } +} + +// The run Bots a workflow spawned, keyed by the node that owns them. +const nodeBots = (rt) => { + const byNode = new Map() + for (const bot of rt.state?.bots ? Object.values(rt.state.bots) : []) { + if (bot.ephemeral === undefined) continue + byNode.set(bot.fields?.['flybot-workflow']?.nodeId ?? bot.fields?.nodeId, bot) + } + return byNode +} + +test('the built-in delivery nodes make github available exactly where the node names it', async (t) => { + const { rt, guardFor, hiddenFrom, stop } = await start() + t.after(stop) + const { enabled, service, dispose } = await applyWorkflow(rt.ctx, { persist: false }) + assert.equal(enabled, true) + t.after(() => dispose?.()) + const assigned = await service.assign({ workflowId: DELIVERY_ID, taskId: 'task-1521', title: 'Ship it' }) + assert.equal(assigned.ok, true, assigned.error ?? '') + // Each edge carries one output, so a step has to produce it for the next node to start. + const outputs = { planner: { plan: 'the plan' }, writer: { copy: 'the copy' }, coder: { patch: 'the patch' }, reviewer: { review: 'lgtm' }, tester: { report: 'green' } } + for (const nodeId of ['planner', 'writer', 'coder', 'reviewer', 'tester']) { + const state = await service.advance({ workflowId: DELIVERY_ID, nodeId, outcome: 'done', outputs: outputs[nodeId] }) + assert.notEqual(state?.ok, false, `${nodeId} advanced: ${state?.error ?? ''}`) + } + const byNode = nodeBots(rt) + for (const nodeId of ['planner', 'writer', 'coder', 'reviewer', 'tester', 'deployer']) { + assert.ok(byNode.get(nodeId), `${nodeId} spawned a run Bot`) + } + + for (const nodeId of ['coder', 'reviewer', 'deployer']) { + const bot = byNode.get(nodeId) + const refusal = guardFor(bot.id, GITHUB[1]) + assert.equal(refusal, undefined, `${nodeId} may call a github tool: ${refusal ?? ''}`) + assert.ok(bot.stepTools?.includes('github'), `${nodeId} recorded github as a step tool`) + assert.deepEqual(hiddenFrom(bot.id), COMPOSIO, `${nodeId} keeps github and hides composio`) + assert.match(guardFor(bot.id, COMPOSIO[0]), /not granted to this run/, `${nodeId} may not call composio`) + } + + // The tester names bash only, and planner/writer name nothing: no connector for either. + const tester = byNode.get('tester') + assert.deepEqual(tester.stepTools, ['bash']) + assert.match(guardFor(tester.id, GITHUB[0]), /not granted to this run/) + for (const nodeId of ['planner', 'writer']) { + const bot = byNode.get(nodeId) + assert.equal(bot.stepTools, undefined, `${nodeId} names no connector, so the host stores no step tools`) + assert.match(guardFor(bot.id, GITHUB[0]), /not granted to this run/, `${nodeId} reaches no github tool`) + } +}) + +test('a node Bot still keeps full file/shell rights and the team tools stay refused', async (t) => { + const { ctx, rt, guardFor, stop } = await start() + t.after(stop) + const fb = rt.flybot.scope('flybot-workflow') + const id = await fb.bot.create({ + name: 'Runner ab', role: 'Run', ephemeral: true, permission: 'danger-full-access', + tools: ['github', 'bash', 'read', 'message_bot', 'request_secret'], + }) + const record = rt.botOf(id) + assert.deepEqual(record.stepTools, ['github', 'bash', 'read', 'message_bot', 'request_secret'], 'the declared list is the step tool list') + assert.equal(record.permission, 'danger-full-access', 'file and shell rights are a normal Bot\'s') + assert.equal(guardFor(id, GITHUB[0]), undefined, 'the connector the step named is allowed') + // ② a connector the step did not name, and ④ a team tool even though the step named it + assert.match(guardFor(id, COMPOSIO[0]), /not granted to this run/) + assert.equal(guardFor(id, 'message_bot'), EPHEMERAL_REFUSAL) + assert.equal(guardFor(id, 'request_secret'), EPHEMERAL_REFUSAL) + // ③ the Bot's own switch still wins: a turned-off connector is refused, and hidden + const agent = await ctx.sessionController.resolveAgent(id) + const listedNow = () => ctx.tools.schemas(agent.agent).map(schema => schema.name).filter(name => name.startsWith('mcp__')).sort() + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(listedNow(), [...GITHUB].sort(), 'github is listed, composio is not') + record.connectors = { github: false } + 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.deepEqual(listedNow(), [], 'both connectors are hidden once the Bot switch is off') +}) diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 4f6d4db57..a74d691a6 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -29,7 +29,7 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, | `plugins/workflow/index.js` | Plugin entry (`flybot-workflow`). Endpoints, `run_workflow`, `define_workflow`, the board listener, and the reply middleware that ingests a step result and denies delivery to persistent Bot chats. | | `plugins/workflow/src/graph.js` | Nodes, edges, positions, and named inputs and outputs. A node starts when its forward predecessors are settled and a taken edge reaches it. Same-rank nodes run together. A forward cycle is refused. A loop edge is the only cycle for success paths. A `when` edge chooses a branch; the other is skipped. A required input with no upstream provider is refused before a run starts. An `onFail` edge provides failure recovery: when a node fails with recovery attempts remaining, the workflow resets to the upstream target and re-runs through the failing node (tracked by `failAttempts` per edge, with a configurable `max` bound, 1–8, default 3). After attempts are exhausted, the node fails permanently. | | `plugins/workflow/src/service.js` | The store, lanes (active runs, FIFO queue, terminal history), `maxConcurrent`, `rename`, `delete`, `saveGraph`, `define`, `runNamed`, `ingest`, `stepResult`. `rename` updates a workflow's name and optional description; `delete` removes the workflow, releases ephemeral Bots, and cleans logs/runs/routes. Factory template ids (e.g. `delivery`) are normal saved rows and may be deleted. A spawn hook makes one run Bot per node. Disposing the plugin ends every live run still marked `running` (nodes in flight are failed, the run is archived) and then releases every run Bot and drops those ids from the saved record only once the release has finished, handing the release back as a promise the host awaits, so a shutdown cannot persist a run as `running` with no ephemeral map and cannot leave hidden Bots that no state names (#1387, #1414). A released run Bot also gives its runner name back to the allocator instead of keeping it for the lifetime of the process. A dispatch that does not land is not marked handed. | -| `plugins/workflow/src/ephemeral.js` | Builds each node's run Bot from its prompt, role, model and tools, and releases the ids the host returns. The `tools` field is **always omitted** from `fb.bot.create` so every node Bot inherits all registered tools (GitHub, Composio, file/shell tools, and team tools limited by `EPHEMERAL_DENIED_TOOLS` at the guard layer). `node.tools` is stored as documentation only in `fields.tools` but does not restrict the grant. All node Bots get `danger-full-access` permission. | +| `plugins/workflow/src/ephemeral.js` | Builds each node's run Bot from its prompt, role, model and tools, and releases the ids the host returns. `node.tools` names the connectors that node may call: `spawn` passes the cleaned list to `fb.bot.create`, the host records it as `stepTools`, and `connector-access.js` allows only the connectors it names (`github` covers `mcp__github__*`, `composio` covers `mcp__composio__*`) — a node that names none reaches none. File and shell rights do not come from that list: every node Bot gets the `danger-full-access` preset a normal sidebar Bot has, and `EPHEMERAL_DENIED_TOOLS` still refuses the team tools at the guard and approval layers. The list is also stored in `fields.tools` for the UI. | | `plugins/workflow/src/match.js` | Which open card matches a Workflow or a local Bot. | | `plugins/workflow/src/template.js` | 发布交付流水线 (a parallel branch) and the weekly report, each node with the inputs and outputs that edge carries. | | `plugins/workflow/src/narrate.js` | Progress lines for a step. A live run quotes the Bot reply when present; otherwise delivery-template nodes may use canned copy. A join line is only for a rank of more than one node. | @@ -49,7 +49,7 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, 3. **A live run advances only when its ephemeral Bot replies, following the graph's edges in order.** That reply advances the run and may appear on the workflow stage log; it is not delivered into a persistent sidebar Bot conversation (for example Chief). The `workflow-step` send middleware denies those replies after `stepResult` runs. Nodes start only when their forward predecessors finish and edges reach them. There is no scripted or seeded run path: `workflow/state` exposes only `live === true` runs. A run does not create a schedule. `scheduleLabel` is display text. When the host can create Bots, starting a live run (`assign`, `run_workflow`, `ingest`) spawns one ephemeral Bot per node from that node's prompt, role, model and tools, sends it the step from the Main Bot, and removes every instance when the run finishes, fails for good, or is reset. A `failOnce` retry keeps them. They are hidden and left out of the sidebar. Loading the plugin does not spawn Bots for the default workflows. Plugin unload (`dispose`) and the load-time `reap` both end a live run still marked `running`: in-flight nodes are failed, the run is archived as `failed`, and any ephemeral Bot ids are released. A run already written as `running` with no ephemeral map (an older unload) is healed the same way on the next load (#1414). Loading is also the one moment a run the host left behind is noticed when its ephemeral map is still present: `reap` releases those instances and records the run as `failed`. Nothing resumes it. A run that already ended keeps its own status (#1388). `workflow/reset` stopping one live run ends it the same way before its Bots are released: nodes still `running` become `failed`, so the strip cannot keep painting a run that is over (#1424). A dispatch that does not land is not marked handed. The chat names the code, and the next hand-off tries that node again. It is not sent twice in the same hand-off. A reply that says `could not answer your message` or `model call failed` fails that node and does not start its successor. A 429 or rate-limit mention in any other reply does not. 4. Each workflow keeps a FIFO queue and up to `maxConcurrent` live runs (default 2, an integer from 1 to 8 on the record, via `workflow/concurrency`, or in Settings → Workflow graphs). A task that does not fit is enqueued. When a run finishes, every workflow with a free slot is drained, oldest enqueue first. A new assign on a workflow that already has a queue waits behind it. A task does not replace a running run or release that run's Bots. Migrating an old store keeps only runs that were still `running` with ephemeral Bot ids; other legacy scripted rows are dropped from active state (finished tasks may remain in lane history). History records a finished or failed live run. Lowering the cap does not stop a run that has already started. Assigning `{ botId }` claims the card for that local Bot and does not enqueue. A remote Bot is refused. `activeCount` is `runningOf(lane)` — live runs with `live === true` and `status === 'running'`. The stage aggregate chip and the Runs tab (运行记录) must agree: every such run appears as its own running row and progress card, and the chip count does not exceed that list. 5. A node declares up to eight inputs and eight outputs. A required input is an output of the same name on an ancestor, or `task`, `title`, or `brief` on a root. A start with no provider is refused, and Bots spawned for that attempt are released. On a Bot-backed run a successor stays queued until its required inputs are present. If every forward predecessor is done, failed or skipped and an input is still missing, that node fails and the run stops. The dispatch text carries `Input : `. The Bot reports `OUTPUT : `; a single declared output may be the reply body. Nodes start in edge order. -6. Each node is a real ephemeral Bot for that run, then it is removed. A permanent sidebar Bot (`botId`) is optional and is not required for the step to run. **Every node Bot gets the same full permissions a normal sidebar Bot gets:** the `tools` field is always omitted from `fb.bot.create`, letting the Bot inherit all registered tools — file/shell tools, GitHub (`mcp__github__*`: pull requests, reviews, comments), Composio, and team tools — limited only by `EPHEMERAL_DENIED_TOOLS` at the guard/approval layer (#1466, 29 team-admin tools). `node.tools` is stored as documentation in `fields.tools` but does not restrict the grant. All node Bots get `danger-full-access` permission. A hidden run Bot cannot show an approval card, so a granted tool is allowed and a connector it did not name is refused. The host stores this on the run Bot as `stepTools` and `permission`. +6. Each node is a real ephemeral Bot for that run, then it is removed. A permanent sidebar Bot (`botId`) is optional and is not required for the step to run. **File and shell rights are the same as a normal sidebar Bot's:** every node Bot gets the `danger-full-access` preset, and `EPHEMERAL_DENIED_TOOLS` still refuses the team-admin tools at the guard and approval layers (#1466, 29 names). **`node.tools` selects connectors only:** `spawn` passes the node's cleaned list to `fb.bot.create`, the host records it as `stepTools`, and `connector-access.js` allows `mcp__github__*` for `github` and `mcp__composio__*` for `composio` — a connector the node did not name is refused, and a node that names none reaches none. The list is also stored in `fields.tools` for the UI. A hidden run Bot cannot show an approval card, so the approval seam answers the same way the guard does. The host stores this on the run Bot as `stepTools` and `permission`. 7. A loop edge (`loop: true`, `max` from 1 to 8, default 3, optional `until: { output, eq }`) is the only cycle for success paths. The body runs again until `until` matches or `max` runs have finished, and the forward edges wait until then. Restarting the body drops the branch choice made inside it and queues a sibling that the previous pass skipped, so the next pass can take the other side. A `when: { output, eq }` edge is taken only when that output equals `eq`. `when: { else: true }` is taken only when no other `when` matched. A node whose forward edges are all closed is `skipped` and does not block a join. A run cannot spin: every loop has a max. An `onFail` edge (`onFail: true`, `max` from 1 to 8, default 3) provides failure recovery: when a node fails, if an `onFail` edge points from that node back to an upstream target and recovery attempts remain (tracked in `failAttempts` per edge), the workflow resets both nodes to their pre-failure states (the target to `running`, the failing node to `queued`) and re-runs from the target through the failing node. After `max` attempts are exhausted, the node fails permanently and the run stops. An `onFail` edge does not interfere with `failOnce` retry (which restarts the same node once) or success-path `loop` edges. Settings → Workflow graphs can add a link, a condition, a loop, or a failure recovery edge. 8. Auto-assign considers an `open` card only. A saved Bot claim beats a workflow trigger. Otherwise first hit wins: a `workflow:` token, then `workflowId` / `workflow`, then an assignee that names a workflow, then a `bot:` token, then an assignee that names a local Bot, then the longest trigger contained in the title, note or criteria. A miss returns no route. A second ingest of the same task and target is a no-op, and a live run for that task id is not restarted. 9. The browser draws text only. Names and messages are React text children. The unit renderer asserts a hostile name is not markup. @@ -59,4 +59,4 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, ## Tests -`plugins/workflow/test/`: graph advance and join, graph edit, assign and persistence, the FIFO queue and concurrency cap, Bot-reply advance, a dispatch that fails without being marked handed, disposing releases the Bots before the record drops their ids and a released runner name is reusable again, assign-to-bot versus assign-to-workflow, inputs and outputs along edges, a loop that breaks and an if/else branch, failure recovery (onFail edges with max attempts, exhaustion behavior, and compatibility with failOnce and loop edges), templates, `define` / `runNamed`, the matcher, ephemeral spawn and cleanup, a stopped run with its settled node map (#1424), the reply middleware, the client canvas, the settings page (including a condition and a loop), rename and delete (including factory-id rows such as `delivery`), and a sidebar click that leaves the Workflows section in place. Run `npm test` in `plugins/workflow`. The host seam that applies `ownerBotId` is `core/test/task-route.test.js`. The host omission of run Bots, and the grant of `stepTools` plus the hidden-approval answer, is `core/test/flybot.test.js`. Which connectors a run Bot may call is `core/test/connector-access.test.js`. +`plugins/workflow/test/`: graph advance and join, graph edit, assign and persistence, the FIFO queue and concurrency cap, Bot-reply advance, a dispatch that fails without being marked handed, disposing releases the Bots before the record drops their ids and a released runner name is reusable again, assign-to-bot versus assign-to-workflow, inputs and outputs along edges, a loop that breaks and an if/else branch, failure recovery (onFail edges with max attempts, exhaustion behavior, and compatibility with failOnce and loop edges), templates, `define` / `runNamed`, the matcher, ephemeral spawn and cleanup, a stopped run with its settled node map (#1424), the reply middleware, the client canvas, the settings page (including a condition and a loop), rename and delete (including factory-id rows such as `delivery`), and a sidebar click that leaves the Workflows section in place. Run `npm test` in `plugins/workflow`. The host seam that applies `ownerBotId` is `core/test/task-route.test.js`. The host omission of run Bots, and the grant of `stepTools` plus the hidden-approval answer, is `core/test/flybot.test.js`. Which connectors a run Bot may call is `core/test/connector-access.test.js`, and the chain from a node's `tools` through `spawn` to that guard — the built-in delivery nodes included — is `core/test/workflow-node-connectors.test.js`. diff --git a/patches/ds-bot/files/test/workflow-node-connectors.test.js b/patches/ds-bot/files/test/workflow-node-connectors.test.js new file mode 100644 index 000000000..7fd3f4dd4 --- /dev/null +++ b/patches/ds-bot/files/test/workflow-node-connectors.test.js @@ -0,0 +1,173 @@ +// #1521 (C): a Workflow node's `tools` selects connectors and nothing else. The node Bot keeps +// the file and shell rights a normal sidebar Bot has, and a connector is available only when the +// node named it — `github` for `mcp__github__*`, `composio` for `mcp__composio__*`. This is the +// end-to-end seam: the built-in delivery template really spawns through the plugin, the host really +// writes `stepTools`, and the host's own connector guard is what answers. +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { apply as applyWorkflow } from '../../plugins/workflow/index.js' +import { DELIVERY_ID } from '../../plugins/workflow/src/template.js' +import { createRuntime, provideFlyBot } from '../index.js' +import { EPHEMERAL_REFUSAL } from '../src/host/tools.js' +import { fakeContext } from './fake-ctx.js' + +const GITHUB = ['mcp__github__create_issue', 'mcp__github__get_me'] +const COMPOSIO = ['mcp__composio__COMPOSIO_SEARCH_TOOLS'] +// What a real profile serves: the connectors and a few ordinary tools. +const SERVED = [...GITHUB, ...COMPOSIO, 'read', 'edit', 'bash', 'message_bot', 'request_secret'] + +const team = () => ({ + version: 1, + mainBotIds: ['a'], + bots: { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1 } }, + rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0, +}) + +async function start() { + const home = mkdtempSync(join(tmpdir(), 'flybot-node-connectors-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(team())) + const ctx = fakeContext({ + services: { + permissionPresets: { + catalog: () => ({ options: [{ value: 'danger-full-access' }, { value: 'auto' }], defaultPreset: 'danger-full-access' }), + resolve: () => {}, + set: () => {}, + }, + }, + }) + const agents = new Map() + const masks = new Map() + const agentOf = async (id) => { + if (agents.has(id)) return agents.get(id) + const agent = { + id, + followup() {}, + steer() {}, + inbox: { nextStep: [], nextTurn: [] }, + session: { id, header: {} }, + ctx: { + tools: { + restrict: ({ deny }) => { + const mask = new Set(deny) + masks.set(id, [...(masks.get(id) ?? []), mask]) + ctx.emit('tools/change') + return () => { masks.set(id, (masks.get(id) ?? []).filter(entry => entry !== mask)); ctx.emit('tools/change') } + }, + }, + effect: execute => execute(), + }, + } + agents.set(id, agent) + await Promise.all((ctx.listeners.get('agent/created') ?? []).map(listener => listener({ agent }))) + return agent + } + const listed = id => new Set([...(masks.get(id) ?? [])].flatMap(mask => [...mask])) + ctx.tools.schemas = agent => SERVED.filter(name => !listed(agent?.id).has(name)).map(name => ({ name })) + let sessions = 0 + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { + create: async () => ({ sessionId: `run${++sessions}` }), + resolveAgent: async id => ({ agent: await agentOf(id) }), + selectModel: async () => {}, + page: async () => ({ records: [], hasMore: false }), + }, + sessions: { get: id => agents.get(id)?.session, flush: async () => {} }, + sessionTitle: { rename: async () => {} }, + }) + const rt = createRuntime(ctx, { home, github: false, composio: false, diagnosticsFile: false }) + provideFlyBot(ctx, rt.flybot) + await rt.load() + // DSH asks the guards before it dispatches, and a guard's answer ends the call. + const guardFor = (id, name) => ctx.guards + .map(check => check({ name, agent: id === undefined ? undefined : { id, session: agents.get(id)?.session } })) + .find(reason => reason !== undefined) + const hiddenFrom = id => [...listed(id)].filter(name => name.startsWith('mcp__')) + const stop = async () => { + await ctx.dispose() + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + } + return { ctx, rt, home, agents, agentOf, guardFor, hiddenFrom, stop } +} + +// The run Bots a workflow spawned, keyed by the node that owns them. +const nodeBots = (rt) => { + const byNode = new Map() + for (const bot of rt.state?.bots ? Object.values(rt.state.bots) : []) { + if (bot.ephemeral === undefined) continue + byNode.set(bot.fields?.['flybot-workflow']?.nodeId ?? bot.fields?.nodeId, bot) + } + return byNode +} + +test('the built-in delivery nodes make github available exactly where the node names it', async (t) => { + const { rt, guardFor, hiddenFrom, stop } = await start() + t.after(stop) + const { enabled, service, dispose } = await applyWorkflow(rt.ctx, { persist: false }) + assert.equal(enabled, true) + t.after(() => dispose?.()) + const assigned = await service.assign({ workflowId: DELIVERY_ID, taskId: 'task-1521', title: 'Ship it' }) + assert.equal(assigned.ok, true, assigned.error ?? '') + // Each edge carries one output, so a step has to produce it for the next node to start. + const outputs = { planner: { plan: 'the plan' }, writer: { copy: 'the copy' }, coder: { patch: 'the patch' }, reviewer: { review: 'lgtm' }, tester: { report: 'green' } } + for (const nodeId of ['planner', 'writer', 'coder', 'reviewer', 'tester']) { + const state = await service.advance({ workflowId: DELIVERY_ID, nodeId, outcome: 'done', outputs: outputs[nodeId] }) + assert.notEqual(state?.ok, false, `${nodeId} advanced: ${state?.error ?? ''}`) + } + const byNode = nodeBots(rt) + for (const nodeId of ['planner', 'writer', 'coder', 'reviewer', 'tester', 'deployer']) { + assert.ok(byNode.get(nodeId), `${nodeId} spawned a run Bot`) + } + + for (const nodeId of ['coder', 'reviewer', 'deployer']) { + const bot = byNode.get(nodeId) + const refusal = guardFor(bot.id, GITHUB[1]) + assert.equal(refusal, undefined, `${nodeId} may call a github tool: ${refusal ?? ''}`) + assert.ok(bot.stepTools?.includes('github'), `${nodeId} recorded github as a step tool`) + assert.deepEqual(hiddenFrom(bot.id), COMPOSIO, `${nodeId} keeps github and hides composio`) + assert.match(guardFor(bot.id, COMPOSIO[0]), /not granted to this run/, `${nodeId} may not call composio`) + } + + // The tester names bash only, and planner/writer name nothing: no connector for either. + const tester = byNode.get('tester') + assert.deepEqual(tester.stepTools, ['bash']) + assert.match(guardFor(tester.id, GITHUB[0]), /not granted to this run/) + for (const nodeId of ['planner', 'writer']) { + const bot = byNode.get(nodeId) + assert.equal(bot.stepTools, undefined, `${nodeId} names no connector, so the host stores no step tools`) + assert.match(guardFor(bot.id, GITHUB[0]), /not granted to this run/, `${nodeId} reaches no github tool`) + } +}) + +test('a node Bot still keeps full file/shell rights and the team tools stay refused', async (t) => { + const { ctx, rt, guardFor, stop } = await start() + t.after(stop) + const fb = rt.flybot.scope('flybot-workflow') + const id = await fb.bot.create({ + name: 'Runner ab', role: 'Run', ephemeral: true, permission: 'danger-full-access', + tools: ['github', 'bash', 'read', 'message_bot', 'request_secret'], + }) + const record = rt.botOf(id) + assert.deepEqual(record.stepTools, ['github', 'bash', 'read', 'message_bot', 'request_secret'], 'the declared list is the step tool list') + assert.equal(record.permission, 'danger-full-access', 'file and shell rights are a normal Bot\'s') + assert.equal(guardFor(id, GITHUB[0]), undefined, 'the connector the step named is allowed') + // ② a connector the step did not name, and ④ a team tool even though the step named it + assert.match(guardFor(id, COMPOSIO[0]), /not granted to this run/) + assert.equal(guardFor(id, 'message_bot'), EPHEMERAL_REFUSAL) + assert.equal(guardFor(id, 'request_secret'), EPHEMERAL_REFUSAL) + // ③ the Bot's own switch still wins: a turned-off connector is refused, and hidden + const agent = await ctx.sessionController.resolveAgent(id) + const listedNow = () => ctx.tools.schemas(agent.agent).map(schema => schema.name).filter(name => name.startsWith('mcp__')).sort() + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(listedNow(), [...GITHUB].sort(), 'github is listed, composio is not') + record.connectors = { github: false } + 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.deepEqual(listedNow(), [], 'both connectors are hidden once the Bot switch is off') +}) diff --git a/plugins/workflow/README.md b/plugins/workflow/README.md index fbfa4f6c7..d83c8df03 100644 --- a/plugins/workflow/README.md +++ b/plugins/workflow/README.md @@ -20,7 +20,7 @@ This is not the workflow-definition engine (`workflows/.json`, #1272). This ## A live run -Assigning a task, `run_workflow`, or a Workflow match starts a live run. One hidden ephemeral Bot is created per node, used for that step, and removed when the run finishes. A node does not need a permanent sidebar Bot. `tools` on the node is what that Bot can use: `github` grants the GitHub connector (pull requests, reviews, comments), `bash` grants a shell at full access, and every step gets at least workspace write. The Main Bot sends that node its step. The node stays `running` until that Bot replies. The reply is the chat line for the step. The next node starts only after that reply, and only along the edges: every predecessor is done first, and nodes that share a rank run together. +Assigning a task, `run_workflow`, or a Workflow match starts a live run. One hidden ephemeral Bot is created per node, used for that step, and removed when the run finishes. A node does not need a permanent sidebar Bot. `tools` on the node selects the connectors that Bot may call: `github` grants the GitHub connector (pull requests, reviews, comments) and `composio` the Composio one, and a connector the node does not name is refused. File and shell access does not come from that list: every node Bot has the same full access a normal sidebar Bot has. The Main Bot sends that node its step. The node stays `running` until that Bot replies. The reply is the chat line for the step. The next node starts only after that reply, and only along the edges: every predecessor is done first, and nodes that share a rank run together. A second task does not replace a run that is already going, and it does not release that run's Bots. It waits in a FIFO queue. The stage shows a chip when more than one run is active or the queue is not empty, and it refreshes `workflow/state` while a live run exists. That refresh does not advance the graph. The product UI does not offer a canned demo replay; only live runs advance the graph. @@ -57,7 +57,7 @@ A reply that says the model could not answer (`could not answer your message` or A local Bot uses the same store: - `run_workflow` — `workflow` (id or exact name) and `brief` (the task). Optional `task` id. This joins the same queue a manual assign uses. -- `define_workflow` — `name`, `nodes` (`{ id, name, prompt?, role?, model?, tools?, botId?, inputs?, outputs?, x?, y? }`), `edges` (`{ from, to, when?, loop?, max?, until? }`). `prompt` is that node's Bot system prompt. `tools` equips the ephemeral Bot (`github`, `bash`, `read`, `edit`). `botId` is optional. `inputs` and `outputs` are `{ name, type?, required? }`. `when` chooses an edge; `loop: true` with `max` is the only cycle. Pass `workflow` to replace one. A friend's Bot is refused. This writes the same graph Settings and the flowchart edit. +- `define_workflow` — `name`, `nodes` (`{ id, name, prompt?, role?, model?, tools?, botId?, inputs?, outputs?, x?, y? }`), `edges` (`{ from, to, when?, loop?, max?, until? }`). `prompt` is that node's Bot system prompt. `tools` names the connectors the ephemeral Bot may call (`github`, `composio`); a node that names none reaches none. `botId` is optional. `inputs` and `outputs` are `{ name, type?, required? }`. `when` chooses an edge; `loop: true` with `max` is the only cycle. Pass `workflow` to replace one. A friend's Bot is refused. This writes the same graph Settings and the flowchart edit. ## Install @@ -83,7 +83,7 @@ Workflow 是一组本地 Bot 的群聊式运行。侧栏打开同一次运行: 把看板任务交给一个 Workflow,会开始一次真实运行,或在已有运行占满时排进 FIFO 队列。交给一个本地 Bot,只把卡片的负责人设成该 Bot,不会启动 Workflow。远程 Bot 会被拒绝。 -一次真实运行会为每个节点创建一个隐藏的临时 Bot,用完即删。节点不必绑定侧栏里的永久 Bot。`tools` 决定这个 Bot 能用什么:`github` 授予 GitHub 连接器(拉取请求、评审、评论),`bash` 授予完整权限的 shell,其余步骤至少可以写工作区。节点一直处于运行中,直到该 Bot 回复。回复写入 Workflow 聊天,下一节点才沿边开始。正在进行的运行不会被新任务替换,也不会因此释放它的 Bot。历史里只留下已完成或已失败的运行。 +一次真实运行会为每个节点创建一个隐藏的临时 Bot,用完即删。节点不必绑定侧栏里的永久 Bot。`tools` 决定这个 Bot 能调用哪些连接器:`github` 授予 GitHub 连接器(拉取请求、评审、评论),`composio` 授予 Composio,没写进去的连接器一律拒绝。文件与 shell 权限不来自这个列表:每个节点 Bot 都和侧栏里的普通 Bot 一样有完整权限。节点一直处于运行中,直到该 Bot 回复。回复写入 Workflow 聊天,下一节点才沿边开始。正在进行的运行不会被新任务替换,也不会因此释放它的 Bot。历史里只留下已完成或已失败的运行。 每个 Workflow 同时运行的上限是 `maxConcurrent`,默认 2,取 1 到 8 的整数,在创建时传入、调用 `workflow/concurrency` 或在设置 → Workflow graphs 里调整。某个运行结束后,所有 Workflow 里最早排队、且自己还有空位的任务会开始。新的分配排在已经排队的任务后面。降低上限不会停掉已经开始的运行。舞台在详情里显示当前上限;有排队或同时多于一个运行时显示队列条。有真实运行时,页面只刷新状态。产品界面不提供预置演示重放,只有真实运行会推进图。 diff --git a/plugins/workflow/index.js b/plugins/workflow/index.js index 02c50bf57..eec79f62d 100644 --- a/plugins/workflow/index.js +++ b/plugins/workflow/index.js @@ -31,10 +31,11 @@ export function apply(ctx, config = {}) { const liveNames = new Map() const lifecycle = typeof fb.bot?.create === 'function' ? { async spawn(spec) { - // Every Workflow node Bot gets the same full tool/connector grant a normal sidebar Bot - // gets (GitHub, Composio, file/shell, team tools), limited only by EPHEMERAL_DENIED_TOOLS - // at the guard layer. spec.tools is documentation only; the tools field is always omitted - // from fb.bot.create so the Bot inherits all available tools. + // A node Bot keeps the file and shell rights a normal sidebar Bot has (permissionFor()). + // spec.tools only selects connectors: the host records it as stepTools, and connector-access.js + // then allows mcp__github__*/mcp__composio__* only for a connector the node named, refusing + // every other one, so a node that declares nothing reaches no connector. EPHEMERAL_DENIED_TOOLS + // still refuses the team tools whatever the node declares. const prompt = spec.prompt let last for (let n = 0; n < 4; n += 1) { @@ -50,6 +51,7 @@ export function apply(ctx, config = {}) { hidden: true, ephemeral: true, permission: permissionFor(), + tools: spec.tools, fields: { nodeId: spec.nodeId, tools: spec.tools ?? null }, }) liveNames.set(name, id) @@ -120,11 +122,11 @@ export function apply(ctx, config = {}) { })) offs.push(fb.tools.register({ name: 'define_workflow', - description: 'Create or replace a Workflow graph. nodes are { id, name, prompt?, role?, model?, tools?, botId?, inputs?, outputs?, x?, y? } and edges are { from, to, when?, loop?, max?, until? }. prompt is that node\'s Bot system prompt. tools name what the ephemeral run Bot may use (github grants mcp__github__*; bash grants a shell). A node does not need a permanent botId. inputs and outputs are { name, type?, required? }. A required input must be produced upstream, or be task, title, or brief on a root. when chooses an edge; loop with max (and optional until) is the only cycle. The Settings page and the flowchart edit the same graph. A friend\'s Bot is refused.', + description: 'Create or replace a Workflow graph. nodes are { id, name, prompt?, role?, model?, tools?, botId?, inputs?, outputs?, x?, y? } and edges are { from, to, when?, loop?, max?, until? }. prompt is that node\'s Bot system prompt. tools names the connectors the ephemeral run Bot may call (github grants mcp__github__*, composio grants mcp__composio__*); the file and shell rights are a normal Bot\'s, and a node that names no connector gets none. A node does not need a permanent botId. inputs and outputs are { name, type?, required? }. A required input must be produced upstream, or be task, title, or brief on a root. when chooses an edge; loop with max (and optional until) is the only cycle. The Settings page and the flowchart edit the same graph. A friend\'s Bot is refused.', parameters: { name: { type: 'string', required: true, description: 'Workflow name' }, workflow: { type: 'string', description: 'Existing id or name to replace. Omit to create.' }, - nodes: { type: 'array', required: true, description: 'Nodes. Each may carry prompt (system prompt), role, model, tools (github, bash, read, edit, …), inputs, outputs, and an optional local botId. Real work uses an ephemeral Bot per run, not a seated botId.' }, + nodes: { type: 'array', required: true, description: 'Nodes. Each may carry prompt (system prompt), role, model, tools (the connectors it may call: github, composio), inputs, outputs, and an optional local botId. Real work uses an ephemeral Bot per run, not a seated botId.' }, edges: { type: 'array', required: true, description: 'Hand-offs { from, to }. A fork is two edges out; a join is two edges in. A condition is when: { output, eq } or { else: true }. A loop is loop: true with max (1–8, default 3) and optional until: { output, eq }.' }, }, execute: args => service.define(args ?? {}), diff --git a/plugins/workflow/src/ephemeral.js b/plugins/workflow/src/ephemeral.js index ffd41309f..483c3f9c4 100644 --- a/plugins/workflow/src/ephemeral.js +++ b/plugins/workflow/src/ephemeral.js @@ -3,11 +3,9 @@ // deletes it when the run finishes, fails for good, or is cancelled. import { cleanLine, cleanPrompt, cleanTools } from './graph.js' -// Every Workflow node Bot gets the full tool/connector grant a normal sidebar Bot gets -// (GitHub, Composio, file/shell tools, team tools), limited only by EPHEMERAL_DENIED_TOOLS -// at the guard/approval layer (#1466). The node.tools field does not restrict this grant; -// it is treated as documentation only. All node Bots get 'danger-full-access' to match -// normal Bots with shell access. +// A Workflow node Bot has the file and shell rights a normal sidebar Bot has: the same +// 'danger-full-access' preset. node.tools selects connectors only (see nodeConfig); the team +// tools stay refused by EPHEMERAL_DENIED_TOOLS at the guard/approval layer (#1466). export function permissionFor() { return 'danger-full-access' } @@ -19,8 +17,9 @@ export function nodeConfig(node, roster = [], workflowModel = '') { const prompt = cleanPrompt(node?.prompt) || cleanPrompt(seated?.systemPrompt || seated?.instructions || '') || cleanPrompt(node?.instructions) - // node.tools is kept as documentation only; spawn always omits the tools field so every - // node Bot inherits the full grant a normal Bot gets, limited by EPHEMERAL_DENIED_TOOLS. + // node.tools names the connectors this node may call: spawn passes it to the host, which records + // it as stepTools, and connector-access.js allows only a connector a node declared. A node that + // names none reaches none. File and shell access comes from permissionFor(), not from here. return { nodeId: node.id, name: node.name, diff --git a/plugins/workflow/test/ephemeral.test.js b/plugins/workflow/test/ephemeral.test.js index c9fb5591f..7144a97dc 100644 --- a/plugins/workflow/test/ephemeral.test.js +++ b/plugins/workflow/test/ephemeral.test.js @@ -9,6 +9,7 @@ import { test } from 'node:test' import { isShownRemoteChat } from '../../../core/src/shared/remote-label.js' import { apply } from '../index.js' import { createWorkflowService } from '../src/service.js' +import { DELIVERY_ID } from '../src/template.js' const roster = [ { id: 'main', name: 'Chief', role: 'lead', isMain: true, remote: false }, @@ -185,7 +186,7 @@ test('a spawn that fails halfway removes the instances it already made and does assert.equal(svc.state().workflows.find(item => item.id === made.id).run, null) }) -test('every node Bot gets full tool grant; node.tools is documentation only', async () => { +test('a node Bot keeps full file/shell rights and passes node.tools as its connector list', async () => { const created = [] const fb = { bot: { @@ -205,18 +206,19 @@ test('every node Bot gets full tool grant; node.tools is documentation only', as } const started = apply({ flybot: { scope: () => fb }, effect(fn) { fn() } }, { persist: false }) - // Node with no tools: tools field omitted, gets full grant, danger-full-access + // Node with no tools: an empty connector list, so the host writes no stepTools and refuses + // every connector. File and shell rights still come from the permission preset. await started.service.define({ name: 'NoTools', nodes: [{ id: 'worker', name: 'Worker', prompt: 'Do the work.' }], edges: [], }) await started.service.runNamed({ workflow: 'NoTools', brief: 'Test no tools' }) - assert.equal('tools' in created[0], false, 'tools field always omitted from fb.bot.create') - assert.equal(created[0].permission, 'danger-full-access', 'all node Bots get full access') + assert.deepEqual(created[0].tools, [], 'a node that names no connector passes an empty list') + assert.equal(created[0].permission, 'danger-full-access', 'file and shell rights are a normal Bot\'s') assert.equal(created[0].systemPrompt, 'Do the work.', 'prompt has no tool-specific notes') - // Node with explicit empty tools: same behavior (tools omitted, full grant) + // Node with explicit empty tools: the same empty connector list created.length = 0 await started.service.define({ name: 'EmptyTools', @@ -224,10 +226,10 @@ test('every node Bot gets full tool grant; node.tools is documentation only', as edges: [], }) await started.service.runNamed({ workflow: 'EmptyTools', brief: 'Test empty' }) - assert.equal('tools' in created[0], false, 'tools field omitted even when node.tools is []') + assert.deepEqual(created[0].tools, [], 'tools: [] names no connector') assert.equal(created[0].permission, 'danger-full-access') - // Node with explicit tools: STILL gets full grant (tools is documentation only) + // Node with explicit tools: the list travels as the connector list created.length = 0 await started.service.define({ name: 'CustomTools', @@ -235,12 +237,12 @@ test('every node Bot gets full tool grant; node.tools is documentation only', as edges: [], }) await started.service.runNamed({ workflow: 'CustomTools', brief: 'Test custom' }) - assert.equal('tools' in created[0], false, 'tools field omitted regardless of node.tools value') - assert.equal(created[0].permission, 'danger-full-access', 'explicit tools do not restrict grant') - assert.deepEqual(created[0].fields.tools, ['read'], 'fields.tools stores node.tools as documentation') + assert.deepEqual(created[0].tools, ['read'], 'node.tools reaches fb.bot.create') + assert.equal(created[0].permission, 'danger-full-access', 'the list does not change file/shell rights') + assert.deepEqual(created[0].fields.tools, ['read'], 'fields.tools keeps the list for the UI') assert.equal(created[0].systemPrompt, 'Just read.', 'no tool notes added') - // Node with bash in tools: same full grant (node.tools does not affect permission) + // Node with bash in tools: the list still travels, and the permission preset is unchanged created.length = 0 await started.service.define({ name: 'ShellTools', @@ -248,7 +250,7 @@ test('every node Bot gets full tool grant; node.tools is documentation only', as edges: [], }) await started.service.runNamed({ workflow: 'ShellTools', brief: 'Test shell' }) - assert.equal('tools' in created[0], false, 'tools field omitted') + assert.deepEqual(created[0].tools, ['bash', 'read']) assert.equal(created[0].permission, 'danger-full-access') }) @@ -284,10 +286,10 @@ test('apply wires spawn to a hidden ephemeral create and removes those Bots when assert.equal(created.length, 1) assert.equal(created[0].ephemeral, true) assert.equal(created[0].hidden, true) - // Every node Bot omits the tools field and gets danger-full-access, inheriting all available - // tools (GitHub, Composio, file/shell, team tools limited by EPHEMERAL_DENIED_TOOLS). + // A node Bot keeps the file/shell preset a normal Bot has and passes its connector list + // (empty for a node that names none) so the host can record stepTools. assert.equal(created[0].permission, 'danger-full-access') - assert.equal('tools' in created[0], false, 'tools field always omitted from fb.bot.create') + assert.deepEqual(created[0].tools, [], 'a node that names no connector passes an empty list') assert.match(created[0].systemPrompt, /Write the plan\./) assert.doesNotMatch(created[0].systemPrompt, /You can use these tools:/, 'no tool-specific notes') assert.equal(created[0].name.startsWith('Planner '), true) @@ -725,3 +727,39 @@ test('an ended run keeps its ending when reap drops the records it still holds ( rmSync(dir, { recursive: true, force: true }) } }) + +test('the built-in delivery nodes hand their connector list to the host', async () => { + const created = [] + const fb = { + bot: { + list: () => roster, + async create(spec) { + created.push(spec) + return `bot-${created.length}` + }, + async remove() { return true }, + async send() { return { action: 'allow' } }, + }, + readOnly: () => false, + api: { handle() { return () => {} } }, + tools: { register() { return () => {} } }, + warn() {}, + } + const started = apply({ flybot: { scope: () => fb }, effect(fn) { fn() } }, { persist: false }) + assert.equal(started.enabled, true) + const run = await started.service.assign({ workflowId: DELIVERY_ID, taskId: 'task-1521', title: 'Ship it' }) + assert.equal(run.ok, true) + // Each edge carries one output, so a step has to produce it for the next node to start. + const outputs = { planner: { plan: 'the plan' }, writer: { copy: 'the copy' }, coder: { patch: 'the patch' }, reviewer: { review: 'lgtm' }, tester: { report: 'green' } } + for (const nodeId of ['planner', 'writer', 'coder', 'reviewer', 'tester']) { + const state = await started.service.advance({ workflowId: DELIVERY_ID, nodeId, outcome: 'done', outputs: outputs[nodeId] }) + assert.notEqual(state?.ok, false, `${nodeId} advanced: ${state?.error ?? ''}`) + } + const byNode = new Map(created.map(spec => [spec.fields.nodeId, spec])) + assert.deepEqual(byNode.get('coder').tools, ['github', 'bash', 'read', 'edit'], 'Coder names github') + assert.deepEqual(byNode.get('reviewer').tools, ['github'], 'Reviewer names github') + assert.deepEqual(byNode.get('deployer').tools, ['github'], 'Deployer names github') + assert.deepEqual(byNode.get('tester').tools, ['bash'], 'Tester names no connector') + assert.deepEqual(byNode.get('planner').tools, [], 'Planner names no connector') + for (const spec of created) assert.equal(spec.permission, 'danger-full-access', 'file and shell rights are a normal Bot\'s') +})