From 5ab3ea6fe0448f124189bd534f878d7d50325976 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 28 Oct 2026 00:42:11 +0000 Subject: [PATCH 1/4] fix(workflow): apply baseline tools (read, edit, bash, github) to node Bots with no explicit tools A Workflow node Bot with no explicit tools list (or an empty list) now gets the baseline tools: read, edit, bash, github. This gives node Bots normal Bot permissions by default, enabling GitHub access and shell operations that node prompts often expect. Before this change: - A node with tools: [] or no tools field got no tools at all - permissionFor([]) returned workspace-write only - Tool notes were not added to the prompt (tools.length === 0) - Escalate and other nodes failed on github/bash with ~6-7 min delays After this change: - A node with no explicit tools gets BASELINE_TOOLS = ['read', 'edit', 'bash', 'github'] - permissionFor() sees the baseline and returns danger-full-access (bash is present) - Tool notes are added to the prompt (github grant, etc.) - Node Bots behave like normal Bots unless explicitly restricted - An explicit tools list still uses those tools only Implementation: - defaultedTools() in ephemeral.js returns BASELINE_TOOLS when tools is empty - nodeConfig() calls defaultedTools() instead of cleanTools() directly - permissionFor() calls defaultedTools() and checks for shell tools as before - Tests updated: baseline tools are verified, explicit tools still work - Docs updated: workflow.md explains the baseline behavior The host-level EPHEMERAL_DENIED_TOOLS (#1466) still narrows what a run Bot may reach (team tools like message_bot, create_bot, etc.), so the baseline does not grant team-admin powers. Co-authored-by: Danyang Chen --- .../workflow-node-bot-baseline-tools.fixed.md | 1 + docs/subsystems/workflow.md | 4 +- plugins/workflow/src/ephemeral.js | 18 ++++-- plugins/workflow/test/ephemeral.test.js | 64 ++++++++++++++++++- 4 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 changelog.d/workflow-node-bot-baseline-tools.fixed.md diff --git a/changelog.d/workflow-node-bot-baseline-tools.fixed.md b/changelog.d/workflow-node-bot-baseline-tools.fixed.md new file mode 100644 index 000000000..691826752 --- /dev/null +++ b/changelog.d/workflow-node-bot-baseline-tools.fixed.md @@ -0,0 +1 @@ +Workflow node Bots now get baseline tools (`read`, `edit`, `bash`, `github`) when no explicit `tools` list is configured, enabling GitHub access and shell operations by default. Previously, nodes with empty or missing `tools` were restricted to `workspace-write` permission only, causing failures when prompts expected GitHub or bash access. diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index d027caadb..7768ab1dd 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. A `when` edge chooses a branch; the other is skipped. A required input with no upstream provider is refused before a run starts. | | `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. `permissionFor` picks `danger-full-access` when the node names a shell, otherwise `workspace-write`. | +| `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. A node with no explicit `tools` (or an empty list) gets the baseline: `read`, `edit`, `bash`, `github`. `permissionFor` picks `danger-full-access` when the node names a shell (or has the baseline, which includes `bash`), otherwise `workspace-write`. | | `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). 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 or via `workflow/concurrency`). The host runs at most 6 live runs at once. A task that does not fit is enqueued. When a run finishes, every workflow with a free slot is drained, oldest enqueue first, until the host cap. 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. `tools` on the node is what that Bot may use: `github` grants `mcp__github__*` (pull requests, reviews, comments); `bash` or `shell` sets the permission to `danger-full-access` and allows a full-access shell; any other step gets `workspace-write`. 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. `tools` on the node is what that Bot may use. A node with no explicit `tools` list (or an empty list) gets the baseline: `read`, `edit`, `bash`, `github` — file operations, shell and GitHub access so the Bot can do normal work. An explicit list uses those tools only. `github` grants `mcp__github__*` (pull requests, reviews, comments); `bash` or `shell` sets the permission to `danger-full-access` and allows a full-access shell. Baseline tools include `bash`, so a node with no explicit list gets `danger-full-access`. Any other step gets `workspace-write`. 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`. 7. A loop edge (`loop: true`, `max` from 1 to 8, default 3, optional `until: { output, eq }`) is the only cycle. 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. Settings → Workflow graphs can add a link, a condition or a loop. 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. diff --git a/plugins/workflow/src/ephemeral.js b/plugins/workflow/src/ephemeral.js index d747ef76a..927c6ca56 100644 --- a/plugins/workflow/src/ephemeral.js +++ b/plugins/workflow/src/ephemeral.js @@ -5,10 +5,20 @@ import { cleanLine, cleanPrompt, cleanTools } from './graph.js' const SHELL = new Set(['bash', 'shell', 'danger-full-access']) -// A step that names a shell gets full access. Anything else can still write -// the workspace. A node with no tools is not left on a read-only default. +// When a node has no explicit tools list, it gets the baseline: file operations, +// shell and connectors. An explicit empty list also gets the baseline, because +// EPHEMERAL_DENIED_TOOLS at the guard/approval layer already narrows what a run +// Bot may reach (#1466), so tools: [] naming nothing reachable is not useful. +// A step that names a shell (or gets it from the baseline) runs with full access. +const BASELINE_TOOLS = ['read', 'edit', 'bash', 'github'] + +export function defaultedTools(tools) { + const explicit = cleanTools(tools) + return explicit.length > 0 ? explicit : BASELINE_TOOLS +} + export function permissionFor(tools) { - const names = cleanTools(tools) + const names = defaultedTools(tools) if (names.some(name => SHELL.has(name))) return 'danger-full-access' return 'workspace-write' } @@ -26,7 +36,7 @@ export function nodeConfig(node, roster = [], workflowModel = '') { role: cleanLine(node.role || seated?.role, 80), prompt, model: cleanLine(workflowModel || node.model || seated?.model, 80), - tools: cleanTools(node.tools), + tools: defaultedTools(node.tools), hidden: true, ephemeral: true, } diff --git a/plugins/workflow/test/ephemeral.test.js b/plugins/workflow/test/ephemeral.test.js index d6bbeabfb..a1858ea9a 100644 --- a/plugins/workflow/test/ephemeral.test.js +++ b/plugins/workflow/test/ephemeral.test.js @@ -185,6 +185,63 @@ 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('a node with no tools gets baseline tools (read, edit, bash, github) and full access permission', async () => { + const created = [] + const fb = { + bot: { + list: () => roster, + async create(spec) { + const id = `bot-${created.length + 1}` + created.push(spec) + return id + }, + 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 }) + + // Node with no tools field gets baseline + const noTools = await started.service.define({ + name: 'NoTools', + nodes: [{ id: 'worker', name: 'Worker', prompt: 'Do the work.' }], + edges: [], + }) + await started.service.runNamed({ workflow: 'NoTools', brief: 'Test baseline' }) + assert.deepEqual(created[0].tools, ['read', 'edit', 'bash', 'github']) + assert.equal(created[0].permission, 'danger-full-access') + assert.match(created[0].systemPrompt, /You can use these tools: read, edit, bash, github\./) + assert.match(created[0].systemPrompt, /GitHub \(mcp__github__\*\) is granted/) + + // Node with explicit empty tools list also gets baseline (no use case for truly empty) + created.length = 0 + const emptyTools = await started.service.define({ + name: 'EmptyTools', + nodes: [{ id: 'worker', name: 'Worker', prompt: 'Do the work.', tools: [] }], + edges: [], + }) + await started.service.runNamed({ workflow: 'EmptyTools', brief: 'Test empty' }) + assert.deepEqual(created[0].tools, ['read', 'edit', 'bash', 'github']) + assert.equal(created[0].permission, 'danger-full-access') + + // Node with explicit tools uses those tools + created.length = 0 + const customTools = await started.service.define({ + name: 'CustomTools', + nodes: [{ id: 'reader', name: 'Reader', prompt: 'Just read.', tools: ['read'] }], + edges: [], + }) + await started.service.runNamed({ workflow: 'CustomTools', brief: 'Test custom' }) + assert.deepEqual(created[0].tools, ['read']) + assert.equal(created[0].permission, 'workspace-write') + assert.match(created[0].systemPrompt, /You can use these tools: read\./) + assert.doesNotMatch(created[0].systemPrompt, /GitHub/) +}) + test('apply wires spawn to a hidden ephemeral create and removes those Bots when the run ends', async () => { const created = [] const removed = [] @@ -217,9 +274,12 @@ 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) - assert.equal(created[0].permission, 'workspace-write') - assert.deepEqual(created[0].tools, []) + // A node with no tools gets the baseline: read, edit, bash, github. bash gives full access. + assert.equal(created[0].permission, 'danger-full-access') + assert.deepEqual(created[0].tools, ['read', 'edit', 'bash', 'github']) assert.match(created[0].systemPrompt, /Write the plan\./) + assert.match(created[0].systemPrompt, /You can use these tools: read, edit, bash, github\./) + assert.match(created[0].systemPrompt, /GitHub \(mcp__github__\*\) is granted/) assert.equal(created[0].name.startsWith('Planner '), true) assert.notEqual(created[0].name, 'Planner') assert.equal(sent.length, 1) From e8be301e0bead23143d63ec389fa80c1e43120d1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 28 Oct 2026 01:05:32 +0000 Subject: [PATCH 2/4] fix(workflow): node Bots inherit all available tools when no explicit list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrected approach: a Workflow node Bot with no explicit tools list now omits the tools field from fb.bot.create, letting it inherit ALL registered tools (file/shell, GitHub, Composio, team tools) just like a normal sidebar Bot, limited only by EPHEMERAL_DENIED_TOOLS at the guard/approval layer. Previous attempt (baseline tools): - Used hardcoded baseline ['read', 'edit', 'bash', 'github'] - Excluded Composio and other connectors - Owner correction: all Bots need same full permissions Current approach: - No explicit tools → tools field omitted from create → inherits all - Explicit tools list → restricts to those tools only - Guard/approval layer handles EPHEMERAL_DENIED_TOOLS (29 team-admin tools) - Node Bots get same permissions as normal Bots (minus team-admin) Implementation: - nodeConfig() returns tools: undefined when node has no explicit list - spawn() omits tools field when undefined, includes it when explicit - permissionFor() handles undefined safely (workspace-write, no shell) - Tests verify: no tools → field omitted; explicit tools → passed through - Docs: ephemeral.js file table + invariant 6 explain inheritance This fixes Escalate and other nodes that were failing on github/bash with 6-7 min delays, and ensures Composio and future connectors work without hardcoding an allowlist. Co-authored-by: Danyang Chen --- .../workflow-node-bot-baseline-tools.fixed.md | 2 +- docs/subsystems/workflow.md | 4 +- plugins/workflow/index.js | 17 ++++--- plugins/workflow/src/ephemeral.js | 23 ++++------ plugins/workflow/test/ephemeral.test.js | 44 ++++++++++++------- 5 files changed, 50 insertions(+), 40 deletions(-) diff --git a/changelog.d/workflow-node-bot-baseline-tools.fixed.md b/changelog.d/workflow-node-bot-baseline-tools.fixed.md index 691826752..b657741d7 100644 --- a/changelog.d/workflow-node-bot-baseline-tools.fixed.md +++ b/changelog.d/workflow-node-bot-baseline-tools.fixed.md @@ -1 +1 @@ -Workflow node Bots now get baseline tools (`read`, `edit`, `bash`, `github`) when no explicit `tools` list is configured, enabling GitHub access and shell operations by default. Previously, nodes with empty or missing `tools` were restricted to `workspace-write` permission only, causing failures when prompts expected GitHub or bash access. +Workflow node Bots now inherit all available tools (file/shell tools, GitHub, Composio, and team tools limited by `EPHEMERAL_DENIED_TOOLS`) when no explicit `tools` list is configured, matching the permissions a normal sidebar Bot gets. Previously, nodes with empty or missing `tools` were restricted to `workspace-write` with no connectors, causing 6-7 minute delays when prompts expected GitHub or bash access. An explicit `tools` list still restricts to those tools only. diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 7768ab1dd..aa411fe95 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. A `when` edge chooses a branch; the other is skipped. A required input with no upstream provider is refused before a run starts. | | `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. A node with no explicit `tools` (or an empty list) gets the baseline: `read`, `edit`, `bash`, `github`. `permissionFor` picks `danger-full-access` when the node names a shell (or has the baseline, which includes `bash`), otherwise `workspace-write`. | +| `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. A node with no explicit `tools` (or an empty list) omits the `tools` field from `fb.bot.create`, letting the Bot inherit all registered tools (GitHub, Composio, file/shell tools, and team tools limited by `EPHEMERAL_DENIED_TOOLS` at the guard layer). An explicit list restricts to those tools. `permissionFor` picks `danger-full-access` when the node names a shell, otherwise `workspace-write`. | | `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). 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 or via `workflow/concurrency`). The host runs at most 6 live runs at once. A task that does not fit is enqueued. When a run finishes, every workflow with a free slot is drained, oldest enqueue first, until the host cap. 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. `tools` on the node is what that Bot may use. A node with no explicit `tools` list (or an empty list) gets the baseline: `read`, `edit`, `bash`, `github` — file operations, shell and GitHub access so the Bot can do normal work. An explicit list uses those tools only. `github` grants `mcp__github__*` (pull requests, reviews, comments); `bash` or `shell` sets the permission to `danger-full-access` and allows a full-access shell. Baseline tools include `bash`, so a node with no explicit list gets `danger-full-access`. Any other step gets `workspace-write`. 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. `tools` on the node is what that Bot may use. A node with no explicit `tools` list (or an empty list) omits the `tools` field, 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). This gives node Bots the same permissions a normal sidebar Bot gets (minus the 29 team-admin tools). An explicit list restricts to those tools only. `bash` or `shell` sets the permission to `danger-full-access` and allows a full-access shell; any other explicit list gets `workspace-write`. A node with no explicit list also gets `workspace-write` (connectors grant themselves). 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`. 7. A loop edge (`loop: true`, `max` from 1 to 8, default 3, optional `until: { output, eq }`) is the only cycle. 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. Settings → Workflow graphs can add a link, a condition or a loop. 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. diff --git a/plugins/workflow/index.js b/plugins/workflow/index.js index b54436512..5566c6e5e 100644 --- a/plugins/workflow/index.js +++ b/plugins/workflow/index.js @@ -31,10 +31,15 @@ export function apply(ctx, config = {}) { const liveNames = new Map() const lifecycle = typeof fb.bot?.create === 'function' ? { async spawn(spec) { - const tools = Array.isArray(spec.tools) ? spec.tools : [] + // spec.tools is undefined when no explicit list is set, letting the Bot inherit all + // available tools (limited only by EPHEMERAL_DENIED_TOOLS at the guard layer). + // An explicit list restricts to those tools. + const tools = spec.tools const notes = [] - if (tools.length > 0) notes.push(`You can use these tools: ${tools.join(', ')}.`) - if (tools.includes('github')) notes.push('GitHub (mcp__github__*) is granted for this step: issues, pull requests, reviews and comments.') + if (Array.isArray(tools) && tools.length > 0) { + notes.push(`You can use these tools: ${tools.join(', ')}.`) + if (tools.includes('github')) notes.push('GitHub (mcp__github__*) is granted for this step: issues, pull requests, reviews and comments.') + } const prompt = [spec.prompt, ...notes].filter(Boolean).join('\n\n') let last for (let n = 0; n < 4; n += 1) { @@ -49,9 +54,9 @@ export function apply(ctx, config = {}) { ...(spec.model ? { model: spec.model } : {}), hidden: true, ephemeral: true, - tools, - permission: permissionFor(tools), - fields: { nodeId: spec.nodeId, tools }, + ...(Array.isArray(tools) ? { tools } : {}), + permission: permissionFor(tools ?? []), + fields: { nodeId: spec.nodeId, tools: tools ?? null }, }) liveNames.set(name, id) return id diff --git a/plugins/workflow/src/ephemeral.js b/plugins/workflow/src/ephemeral.js index 927c6ca56..e3cca6fd5 100644 --- a/plugins/workflow/src/ephemeral.js +++ b/plugins/workflow/src/ephemeral.js @@ -5,20 +5,12 @@ import { cleanLine, cleanPrompt, cleanTools } from './graph.js' const SHELL = new Set(['bash', 'shell', 'danger-full-access']) -// When a node has no explicit tools list, it gets the baseline: file operations, -// shell and connectors. An explicit empty list also gets the baseline, because -// EPHEMERAL_DENIED_TOOLS at the guard/approval layer already narrows what a run -// Bot may reach (#1466), so tools: [] naming nothing reachable is not useful. -// A step that names a shell (or gets it from the baseline) runs with full access. -const BASELINE_TOOLS = ['read', 'edit', 'bash', 'github'] - -export function defaultedTools(tools) { - const explicit = cleanTools(tools) - return explicit.length > 0 ? explicit : BASELINE_TOOLS -} - +// A node with no explicit tools list inherits the full tool grant a normal Bot gets, +// limited only by EPHEMERAL_DENIED_TOOLS at the guard/approval layer (#1466). An +// explicit list restricts to those tools. Permission is 'danger-full-access' when the +// node names a shell, otherwise 'workspace-write'. export function permissionFor(tools) { - const names = defaultedTools(tools) + const names = cleanTools(tools) if (names.some(name => SHELL.has(name))) return 'danger-full-access' return 'workspace-write' } @@ -30,13 +22,16 @@ export function nodeConfig(node, roster = [], workflowModel = '') { const prompt = cleanPrompt(node?.prompt) || cleanPrompt(seated?.systemPrompt || seated?.instructions || '') || cleanPrompt(node?.instructions) + // An explicit tools list is kept; no list (or an empty one) is returned as undefined + // so the spawn inherits all tools a normal Bot gets, limited by EPHEMERAL_DENIED_TOOLS. + const explicit = cleanTools(node.tools) return { nodeId: node.id, name: node.name, role: cleanLine(node.role || seated?.role, 80), prompt, model: cleanLine(workflowModel || node.model || seated?.model, 80), - tools: defaultedTools(node.tools), + tools: explicit.length > 0 ? explicit : undefined, hidden: true, ephemeral: true, } diff --git a/plugins/workflow/test/ephemeral.test.js b/plugins/workflow/test/ephemeral.test.js index a1858ea9a..c79510ed6 100644 --- a/plugins/workflow/test/ephemeral.test.js +++ b/plugins/workflow/test/ephemeral.test.js @@ -185,7 +185,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('a node with no tools gets baseline tools (read, edit, bash, github) and full access permission', async () => { +test('a node with no tools inherits all available tools; explicit tools restrict', async () => { const created = [] const fb = { bot: { @@ -205,19 +205,18 @@ test('a node with no tools gets baseline tools (read, edit, bash, github) and fu } const started = apply({ flybot: { scope: () => fb }, effect(fn) { fn() } }, { persist: false }) - // Node with no tools field gets baseline + // Node with no tools field: tools is omitted from fb.bot.create, inherits all available const noTools = await started.service.define({ name: 'NoTools', nodes: [{ id: 'worker', name: 'Worker', prompt: 'Do the work.' }], edges: [], }) - await started.service.runNamed({ workflow: 'NoTools', brief: 'Test baseline' }) - assert.deepEqual(created[0].tools, ['read', 'edit', 'bash', 'github']) - assert.equal(created[0].permission, 'danger-full-access') - assert.match(created[0].systemPrompt, /You can use these tools: read, edit, bash, github\./) - assert.match(created[0].systemPrompt, /GitHub \(mcp__github__\*\) is granted/) + await started.service.runNamed({ workflow: 'NoTools', brief: 'Test inherit' }) + assert.equal('tools' in created[0], false, 'tools field is omitted when node has no explicit list') + assert.equal(created[0].permission, 'workspace-write', 'no explicit shell tool means workspace-write') + assert.doesNotMatch(created[0].systemPrompt, /You can use these tools:/, 'no tool-specific notes when tools undefined') - // Node with explicit empty tools list also gets baseline (no use case for truly empty) + // Node with explicit empty tools list: same as no tools (undefined) created.length = 0 const emptyTools = await started.service.define({ name: 'EmptyTools', @@ -225,10 +224,10 @@ test('a node with no tools gets baseline tools (read, edit, bash, github) and fu edges: [], }) await started.service.runNamed({ workflow: 'EmptyTools', brief: 'Test empty' }) - assert.deepEqual(created[0].tools, ['read', 'edit', 'bash', 'github']) - assert.equal(created[0].permission, 'danger-full-access') + assert.equal('tools' in created[0], false, 'explicit empty list also omits tools field') + assert.equal(created[0].permission, 'workspace-write') - // Node with explicit tools uses those tools + // Node with explicit tools uses only those tools created.length = 0 const customTools = await started.service.define({ name: 'CustomTools', @@ -236,10 +235,21 @@ test('a node with no tools gets baseline tools (read, edit, bash, github) and fu edges: [], }) await started.service.runNamed({ workflow: 'CustomTools', brief: 'Test custom' }) - assert.deepEqual(created[0].tools, ['read']) + assert.deepEqual(created[0].tools, ['read'], 'explicit tools are passed through') assert.equal(created[0].permission, 'workspace-write') assert.match(created[0].systemPrompt, /You can use these tools: read\./) assert.doesNotMatch(created[0].systemPrompt, /GitHub/) + + // Node with explicit bash gets danger-full-access + created.length = 0 + const shellTools = await started.service.define({ + name: 'ShellTools', + nodes: [{ id: 'worker', name: 'Worker', prompt: 'Run commands.', tools: ['bash', 'read'] }], + edges: [], + }) + await started.service.runNamed({ workflow: 'ShellTools', brief: 'Test shell' }) + assert.deepEqual(created[0].tools, ['bash', 'read']) + assert.equal(created[0].permission, 'danger-full-access', 'explicit bash grants full access') }) test('apply wires spawn to a hidden ephemeral create and removes those Bots when the run ends', async () => { @@ -274,12 +284,12 @@ 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) - // A node with no tools gets the baseline: read, edit, bash, github. bash gives full access. - assert.equal(created[0].permission, 'danger-full-access') - assert.deepEqual(created[0].tools, ['read', 'edit', 'bash', 'github']) + // A node with no tools omits the tools field, inheriting all available tools (limited by + // EPHEMERAL_DENIED_TOOLS at the guard layer). No explicit shell = workspace-write permission. + assert.equal(created[0].permission, 'workspace-write') + assert.equal('tools' in created[0], false, 'tools field omitted when node has no explicit list') assert.match(created[0].systemPrompt, /Write the plan\./) - assert.match(created[0].systemPrompt, /You can use these tools: read, edit, bash, github\./) - assert.match(created[0].systemPrompt, /GitHub \(mcp__github__\*\) is granted/) + assert.doesNotMatch(created[0].systemPrompt, /You can use these tools:/, 'no tool notes when tools undefined') assert.equal(created[0].name.startsWith('Planner '), true) assert.notEqual(created[0].name, 'Planner') assert.equal(sent.length, 1) From 4092f89aa7ed1c54dd5c63277644325403335f2f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 28 Oct 2026 01:09:44 +0000 Subject: [PATCH 3/4] fix(workflow): ALL node Bots get full permissions; node.tools is documentation only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner requirement (final): EVERY Workflow node Bot must get the same full permissions and connectors a normal sidebar Bot gets, INCLUDING when the node has an explicit tools list. node.tools should NOT strip connectors. Final correct implementation: - tools field is ALWAYS omitted from fb.bot.create() for node Bots - Every node Bot inherits all registered tools (GitHub, Composio, file/shell, team tools) - Guard layer (EPHEMERAL_DENIED_TOOLS #1466) restricts only 29 team-admin tools - node.tools is stored in fields.tools as documentation but does not restrict - All node Bots get danger-full-access permission - No tool-specific prompt notes are added This ensures: ✓ Triage, Review, Escalate, Enqueue all get full GitHub/Composio access ✓ LIVE Review node with tools: [bash, read, write, edit] still gets GitHub/Composio ✓ Node tools lists are preserved as documentation without restricting grant ✓ Matches owner ask: 所有的bot要能拿到正常bot的所有权限,包括github、composio等等 Previous attempts: 1. Baseline ['read', 'edit', 'bash', 'github'] - excluded Composio 2. Inherit when empty, restrict when explicit - still stripped connectors Current: Always inherit, never restrict. Simple and correct. Co-authored-by: Danyang Chen --- .../workflow-node-bot-baseline-tools.fixed.md | 2 +- docs/subsystems/workflow.md | 4 +- plugins/workflow/index.js | 20 +++------ plugins/workflow/src/ephemeral.js | 22 +++++----- plugins/workflow/test/ephemeral.test.js | 44 +++++++++---------- 5 files changed, 42 insertions(+), 50 deletions(-) diff --git a/changelog.d/workflow-node-bot-baseline-tools.fixed.md b/changelog.d/workflow-node-bot-baseline-tools.fixed.md index b657741d7..402d7b332 100644 --- a/changelog.d/workflow-node-bot-baseline-tools.fixed.md +++ b/changelog.d/workflow-node-bot-baseline-tools.fixed.md @@ -1 +1 @@ -Workflow node Bots now inherit all available tools (file/shell tools, GitHub, Composio, and team tools limited by `EPHEMERAL_DENIED_TOOLS`) when no explicit `tools` list is configured, matching the permissions a normal sidebar Bot gets. Previously, nodes with empty or missing `tools` were restricted to `workspace-write` with no connectors, causing 6-7 minute delays when prompts expected GitHub or bash access. An explicit `tools` list still restricts to those tools only. +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/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index aa411fe95..36706b904 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. A `when` edge chooses a branch; the other is skipped. A required input with no upstream provider is refused before a run starts. | | `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. A node with no explicit `tools` (or an empty list) omits the `tools` field from `fb.bot.create`, letting the Bot inherit all registered tools (GitHub, Composio, file/shell tools, and team tools limited by `EPHEMERAL_DENIED_TOOLS` at the guard layer). An explicit list restricts to those tools. `permissionFor` picks `danger-full-access` when the node names a shell, otherwise `workspace-write`. | +| `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/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). 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 or via `workflow/concurrency`). The host runs at most 6 live runs at once. A task that does not fit is enqueued. When a run finishes, every workflow with a free slot is drained, oldest enqueue first, until the host cap. 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. `tools` on the node is what that Bot may use. A node with no explicit `tools` list (or an empty list) omits the `tools` field, 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). This gives node Bots the same permissions a normal sidebar Bot gets (minus the 29 team-admin tools). An explicit list restricts to those tools only. `bash` or `shell` sets the permission to `danger-full-access` and allows a full-access shell; any other explicit list gets `workspace-write`. A node with no explicit list also gets `workspace-write` (connectors grant themselves). 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. **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`. 7. A loop edge (`loop: true`, `max` from 1 to 8, default 3, optional `until: { output, eq }`) is the only cycle. 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. Settings → Workflow graphs can add a link, a condition or a loop. 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. diff --git a/plugins/workflow/index.js b/plugins/workflow/index.js index 5566c6e5e..02c50bf57 100644 --- a/plugins/workflow/index.js +++ b/plugins/workflow/index.js @@ -31,16 +31,11 @@ export function apply(ctx, config = {}) { const liveNames = new Map() const lifecycle = typeof fb.bot?.create === 'function' ? { async spawn(spec) { - // spec.tools is undefined when no explicit list is set, letting the Bot inherit all - // available tools (limited only by EPHEMERAL_DENIED_TOOLS at the guard layer). - // An explicit list restricts to those tools. - const tools = spec.tools - const notes = [] - if (Array.isArray(tools) && tools.length > 0) { - notes.push(`You can use these tools: ${tools.join(', ')}.`) - if (tools.includes('github')) notes.push('GitHub (mcp__github__*) is granted for this step: issues, pull requests, reviews and comments.') - } - const prompt = [spec.prompt, ...notes].filter(Boolean).join('\n\n') + // 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. + const prompt = spec.prompt let last for (let n = 0; n < 4; n += 1) { const taken = new Set([...(fb.bot.list?.() ?? []).map(bot => bot.name), ...liveNames.keys()]) @@ -54,9 +49,8 @@ export function apply(ctx, config = {}) { ...(spec.model ? { model: spec.model } : {}), hidden: true, ephemeral: true, - ...(Array.isArray(tools) ? { tools } : {}), - permission: permissionFor(tools ?? []), - fields: { nodeId: spec.nodeId, tools: tools ?? null }, + permission: permissionFor(), + fields: { nodeId: spec.nodeId, tools: spec.tools ?? null }, }) liveNames.set(name, id) return id diff --git a/plugins/workflow/src/ephemeral.js b/plugins/workflow/src/ephemeral.js index e3cca6fd5..e0ca1cc62 100644 --- a/plugins/workflow/src/ephemeral.js +++ b/plugins/workflow/src/ephemeral.js @@ -5,14 +5,13 @@ import { cleanLine, cleanPrompt, cleanTools } from './graph.js' const SHELL = new Set(['bash', 'shell', 'danger-full-access']) -// A node with no explicit tools list inherits the full tool grant a normal Bot gets, -// limited only by EPHEMERAL_DENIED_TOOLS at the guard/approval layer (#1466). An -// explicit list restricts to those tools. Permission is 'danger-full-access' when the -// node names a shell, otherwise 'workspace-write'. -export function permissionFor(tools) { - const names = cleanTools(tools) - if (names.some(name => SHELL.has(name))) return 'danger-full-access' - return 'workspace-write' +// 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. +export function permissionFor() { + return 'danger-full-access' } export function nodeConfig(node, roster = [], workflowModel = '') { @@ -22,16 +21,15 @@ export function nodeConfig(node, roster = [], workflowModel = '') { const prompt = cleanPrompt(node?.prompt) || cleanPrompt(seated?.systemPrompt || seated?.instructions || '') || cleanPrompt(node?.instructions) - // An explicit tools list is kept; no list (or an empty one) is returned as undefined - // so the spawn inherits all tools a normal Bot gets, limited by EPHEMERAL_DENIED_TOOLS. - const explicit = cleanTools(node.tools) + // 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. return { nodeId: node.id, name: node.name, role: cleanLine(node.role || seated?.role, 80), prompt, model: cleanLine(workflowModel || node.model || seated?.model, 80), - tools: explicit.length > 0 ? explicit : undefined, + tools: cleanTools(node.tools), hidden: true, ephemeral: true, } diff --git a/plugins/workflow/test/ephemeral.test.js b/plugins/workflow/test/ephemeral.test.js index c79510ed6..03ac6db10 100644 --- a/plugins/workflow/test/ephemeral.test.js +++ b/plugins/workflow/test/ephemeral.test.js @@ -185,7 +185,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('a node with no tools inherits all available tools; explicit tools restrict', async () => { +test('every node Bot gets full tool grant; node.tools is documentation only', async () => { const created = [] const fb = { bot: { @@ -205,18 +205,18 @@ test('a node with no tools inherits all available tools; explicit tools restrict } const started = apply({ flybot: { scope: () => fb }, effect(fn) { fn() } }, { persist: false }) - // Node with no tools field: tools is omitted from fb.bot.create, inherits all available + // Node with no tools: tools field omitted, gets full grant, danger-full-access const noTools = await started.service.define({ name: 'NoTools', nodes: [{ id: 'worker', name: 'Worker', prompt: 'Do the work.' }], edges: [], }) - await started.service.runNamed({ workflow: 'NoTools', brief: 'Test inherit' }) - assert.equal('tools' in created[0], false, 'tools field is omitted when node has no explicit list') - assert.equal(created[0].permission, 'workspace-write', 'no explicit shell tool means workspace-write') - assert.doesNotMatch(created[0].systemPrompt, /You can use these tools:/, 'no tool-specific notes when tools undefined') + 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.equal(created[0].systemPrompt, 'Do the work.', 'prompt has no tool-specific notes') - // Node with explicit empty tools list: same as no tools (undefined) + // Node with explicit empty tools: same behavior (tools omitted, full grant) created.length = 0 const emptyTools = await started.service.define({ name: 'EmptyTools', @@ -224,10 +224,10 @@ test('a node with no tools inherits all available tools; explicit tools restrict edges: [], }) await started.service.runNamed({ workflow: 'EmptyTools', brief: 'Test empty' }) - assert.equal('tools' in created[0], false, 'explicit empty list also omits tools field') - assert.equal(created[0].permission, 'workspace-write') + assert.equal('tools' in created[0], false, 'tools field omitted even when node.tools is []') + assert.equal(created[0].permission, 'danger-full-access') - // Node with explicit tools uses only those tools + // Node with explicit tools: STILL gets full grant (tools is documentation only) created.length = 0 const customTools = await started.service.define({ name: 'CustomTools', @@ -235,12 +235,12 @@ test('a node with no tools inherits all available tools; explicit tools restrict edges: [], }) await started.service.runNamed({ workflow: 'CustomTools', brief: 'Test custom' }) - assert.deepEqual(created[0].tools, ['read'], 'explicit tools are passed through') - assert.equal(created[0].permission, 'workspace-write') - assert.match(created[0].systemPrompt, /You can use these tools: read\./) - assert.doesNotMatch(created[0].systemPrompt, /GitHub/) + 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.equal(created[0].systemPrompt, 'Just read.', 'no tool notes added') - // Node with explicit bash gets danger-full-access + // Node with bash in tools: same full grant (node.tools does not affect permission) created.length = 0 const shellTools = await started.service.define({ name: 'ShellTools', @@ -248,8 +248,8 @@ test('a node with no tools inherits all available tools; explicit tools restrict edges: [], }) await started.service.runNamed({ workflow: 'ShellTools', brief: 'Test shell' }) - assert.deepEqual(created[0].tools, ['bash', 'read']) - assert.equal(created[0].permission, 'danger-full-access', 'explicit bash grants full access') + assert.equal('tools' in created[0], false, 'tools field omitted') + assert.equal(created[0].permission, 'danger-full-access') }) test('apply wires spawn to a hidden ephemeral create and removes those Bots when the run ends', async () => { @@ -284,12 +284,12 @@ 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) - // A node with no tools omits the tools field, inheriting all available tools (limited by - // EPHEMERAL_DENIED_TOOLS at the guard layer). No explicit shell = workspace-write permission. - assert.equal(created[0].permission, 'workspace-write') - assert.equal('tools' in created[0], false, 'tools field omitted when node has no explicit list') + // 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). + assert.equal(created[0].permission, 'danger-full-access') + assert.equal('tools' in created[0], false, 'tools field always omitted from fb.bot.create') assert.match(created[0].systemPrompt, /Write the plan\./) - assert.doesNotMatch(created[0].systemPrompt, /You can use these tools:/, 'no tool notes when tools undefined') + assert.doesNotMatch(created[0].systemPrompt, /You can use these tools:/, 'no tool-specific notes') assert.equal(created[0].name.startsWith('Planner '), true) assert.notEqual(created[0].name, 'Planner') assert.equal(sent.length, 1) From b9ed05bfa46f3d9b584cb9ff5e509bcbb23d689e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 28 Oct 2026 01:15:12 +0000 Subject: [PATCH 4/4] fix(workflow): remove unused vars SHELL, noTools, emptyTools, customTools, shellTools Lint fix for CI: - Removed unused SHELL constant (permissionFor no longer checks it) - Removed unused var assignments in test (noTools, emptyTools, customTools, shellTools) - Tests still pass (86/86) - Plugins lint now clean Urgent fix per owner request for CI green before Birfy review. Co-authored-by: Danyang Chen --- plugins/workflow/src/ephemeral.js | 2 -- plugins/workflow/test/ephemeral.test.js | 8 ++++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/plugins/workflow/src/ephemeral.js b/plugins/workflow/src/ephemeral.js index e0ca1cc62..ffd41309f 100644 --- a/plugins/workflow/src/ephemeral.js +++ b/plugins/workflow/src/ephemeral.js @@ -3,8 +3,6 @@ // deletes it when the run finishes, fails for good, or is cancelled. import { cleanLine, cleanPrompt, cleanTools } from './graph.js' -const SHELL = new Set(['bash', 'shell', 'danger-full-access']) - // 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; diff --git a/plugins/workflow/test/ephemeral.test.js b/plugins/workflow/test/ephemeral.test.js index 03ac6db10..5a056bef7 100644 --- a/plugins/workflow/test/ephemeral.test.js +++ b/plugins/workflow/test/ephemeral.test.js @@ -206,7 +206,7 @@ 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 - const noTools = await started.service.define({ + await started.service.define({ name: 'NoTools', nodes: [{ id: 'worker', name: 'Worker', prompt: 'Do the work.' }], edges: [], @@ -218,7 +218,7 @@ test('every node Bot gets full tool grant; node.tools is documentation only', as // Node with explicit empty tools: same behavior (tools omitted, full grant) created.length = 0 - const emptyTools = await started.service.define({ + await started.service.define({ name: 'EmptyTools', nodes: [{ id: 'worker', name: 'Worker', prompt: 'Do the work.', tools: [] }], edges: [], @@ -229,7 +229,7 @@ test('every node Bot gets full tool grant; node.tools is documentation only', as // Node with explicit tools: STILL gets full grant (tools is documentation only) created.length = 0 - const customTools = await started.service.define({ + await started.service.define({ name: 'CustomTools', nodes: [{ id: 'reader', name: 'Reader', prompt: 'Just read.', tools: ['read'] }], edges: [], @@ -242,7 +242,7 @@ test('every node Bot gets full tool grant; node.tools is documentation only', as // Node with bash in tools: same full grant (node.tools does not affect permission) created.length = 0 - const shellTools = await started.service.define({ + await started.service.define({ name: 'ShellTools', nodes: [{ id: 'worker', name: 'Worker', prompt: 'Run commands.', tools: ['bash', 'read'] }], edges: [],