feat: add [run.agent] fabro_tools opt-in for worker run tools (#348)

## Summary

Workflow agents previously always received the `agent:run_tools` JWT
scope and had Fabro run tools registered unconditionally. This PR makes
Fabro run tool access an explicit per-run opt-in via `[run.agent]
fabro_tools = true`, defaulting to `false`.

## What changed

**Config layer** (`fabro-types`, `fabro-config`): `RunAgentSettings`
gains a `fabro_tools: bool` field (serialized with `#[serde(default)]`
for backward compatibility). The corresponding `RunAgentLayer` gets an
optional `fabro_tools: Option<bool>` that resolves to `false` when
absent. Layer merging follows the existing `Combine` macro semantics, so
a workflow-level `false` overrides a user-level `true`.

**Server** (`fabro-server`): `worker_command` gains an
`agent_fabro_tools_enabled: bool` parameter. The server reads
`run_state.spec.settings.run.agent.fabro_tools` from the stored run
before spawning `__run-worker`, then issues the worker JWT with either
`["run:worker"]` or `["run:worker", "agent:run_tools"]` accordingly.
`WorkerScopeSet::run_worker()` loses its `#[cfg(test)]` gate so it's
available in production paths.

**CLI worker** (`fabro-cli`): `FabroRunToolServices` construction is now
gated on `run_spec.settings.run.agent.fabro_tools` rather than being
unconditional. The resolved run spec already carries the setting, so no
env-var parsing is needed in the runner.

**Tests**: The single monolithic
`worker_command_always_sets_worker_token_env` test is replaced by two
focused tests — one confirming the default scope is `["run:worker"]`
only, and one confirming the opt-in scope includes `agent:run_tools`.
Shared assertion logic is extracted into
`assert_worker_command_passes_token_only_by_env` and
`worker_token_claims` helpers. Config resolver tests cover default,
explicit true/false, and layer-override behavior.

**Docs**: `[run.agent]` description and reference tables are updated; a
new `run-configuration.mdx` section explains the opt-in semantics before
the existing `[run.agent.mcps]` section.

### Plan Summary

- 1. Add `fabro_tools` to resolved and layered run config, with resolver
tests.
- 2. Gate worker JWT scope and pass setting from stored run state into
`worker_command`.
- 3. Gate CLI `FabroRunToolServices` construction on the resolved
setting.
- 4. Update docs generator sample and public reference/execution docs.
- 5. Full verification pass (nextest, fmt, clippy).


### Fabro Details

<details>
<summary>Ran 9 stages in 54m 4s for $15.21</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 4s | – | 0 |
| preflight_lint | 2m 18s | – | 0 |
| implement | 28m 5s | $10.58 | 0 |
| simplify_opus | 12m 4s | $2.86 | 0 |
| simplify_gpt | 5m 19s | $1.77 | 0 |
| verify | 3m 37s | – | 0 |
| fmt | 3s | – | 0 |
| **Total** | **54m 4s** | **$15.21** | **0** |

</details>

<details>
<summary>Ran <code>ImplementPlan.fabro</code> (12 nodes and 15
edges)</summary>

```dot
digraph ImplementPlan {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { model: claude-opus-4-7; }
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD."]
    simplify_opus     [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
    simplify_gpt      [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
    verify            [label="Verify", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1 && cargo dev docs refresh 2>&1 && cargo dev docs check 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings, test failures, and generated docs errors.", max_visits=3]
    fmt               [label="Format", shape=parallelogram, script="cargo +nightly-2026-04-14 fmt --all 2>&1", max_retries=0]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=succeeded"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=succeeded"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=succeeded"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify_opus -> simplify_gpt -> verify
    verify -> fmt   [condition="outcome=succeeded"]
    verify -> fixup
    fixup -> verify
    fmt -> exit
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: fabro-agent <agent@fabro.local>
This commit is contained in:
fabro-sh-0530[bot] 2026-05-22 09:41:27 -04:00 • committed by GitHub
parent c6356cbd77
commit f5f921aa3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 242 additions and 100 deletions

View file

@ -403,6 +403,17 @@ include = ["test-results/**", "playwright-report/**", "*.trace.zip"]
Artifact collection is opt-in — when no `[run.artifacts]` section is present, no file scanning occurs.
### `[run.agent]`
Configure workflow agent behavior that is not tied to a single stage.
```toml title="run.toml"
[run.agent]
fabro_tools = true
```
`fabro_tools` defaults to `false`. Set it to `true` only for runs whose agents should be able to create, search, inspect, and interact with Fabro runs through the built-in Fabro run tools. This setting is separate from normal agent `permissions` and from MCP server configuration.
### `[run.agent.mcps]`
Configure [MCP servers](/agents/mcp) available to agent stages during the workflow run. Each server is a named TOML table under `[run.agent.mcps]`. All three transport types are supported: `stdio`, `http`, and `sandbox`.

View file

@ -430,15 +430,17 @@ enabled = true
## `[run.agent]`
`[run.agent]` — agent knobs only (permissions, MCPs)
`[run.agent]` — agent knobs only (Fabro tools, permissions, MCPs)
```toml title="settings.toml"
[run.agent]
fabro_tools = true
permissions = "read-write"
```
| Key | Type / values | Default | Description |
|---|---|---|---|
| `fabro_tools` | boolean | false | Allow workflow agents to use Fabro run-management tools. |
| `mcps` | table | None | Agent-scoped MCP server entries, keyed by name. |
| `permissions` | "read-only" \| "read-write" \| "full" | "read-write" | Default tool permission level for workflow agents. |

View file

@ -93,14 +93,18 @@ pub(crate) async fn execute(
client.clone_for_reuse(),
worker_token.to_owned(),
)));
let fabro_run_tools = build_fabro_run_tool_services(
worker_token,
client.clone_for_reuse(),
run_id,
run_spec.source_directory.as_deref(),
&run_dir,
Arc::clone(&catalog),
);
let fabro_run_tools = if run_spec.settings.run.agent.fabro_tools {
build_fabro_run_tool_services(
worker_token,
client.clone_for_reuse(),
run_id,
run_spec.source_directory.as_deref(),
&run_dir,
Arc::clone(&catalog),
)
} else {
None
};
let interviewer = Arc::new(ControlInterviewer::new());
let cancel_token = CancellationToken::new();
let emitter = Arc::new(Emitter::new(run_id));

View file

@ -917,6 +917,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
},
"run": {
"agent": {
"fabro_tools": false,
"mcps": {},
"permissions": null
},

View file

@ -175,6 +175,7 @@ fn inspect_resolves_selector_via_server_endpoint() {
"slack": null
},
"agent": {
"fabro_tools": false,
"permissions": null,
"mcps": {}
},

View file

@ -464,7 +464,7 @@ pub struct InterviewProviderLayer {
pub channel: Option<InterpString>,
}
/// `[run.agent]` — agent knobs only (permissions, MCPs).
/// `[run.agent]` — agent knobs only (Fabro tools, permissions, MCPs).
#[derive(
Debug,
Clone,
@ -477,6 +477,11 @@ pub struct InterviewProviderLayer {
)]
#[serde(deny_unknown_fields)]
pub struct RunAgentLayer {
/// Allow workflow agents to use Fabro run-management tools.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[option(default = "false", value_type = "boolean")]
pub fabro_tools: Option<bool>,
/// Default tool permission level for workflow agents.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[option(
@ -484,10 +489,11 @@ pub struct RunAgentLayer {
value_type = "\"read-only\" | \"read-write\" | \"full\""
)]
pub permissions: Option<AgentPermissions>,
/// Agent-scoped MCP server entries, keyed by name.
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
#[option(value_type = "table")]
pub mcps: StickyMap<McpEntryLayer>,
pub mcps: StickyMap<McpEntryLayer>,
}
/// A single MCP entry. `type` selects the transport; `script`/`command` are

View file

@ -350,6 +350,7 @@ fn resolve_agent(agent: Option<&RunAgentLayer>) -> RunAgentSettings {
};
RunAgentSettings {
fabro_tools: agent.fabro_tools.unwrap_or(false),
permissions: agent.permissions,
mcps: agent
.mcps

View file

@ -532,3 +532,80 @@ issues = "{{ env.GH_PERM_LEVEL }}"
assert_eq!(issues.as_source(), "{{ env.GH_PERM_LEVEL }}");
}
}
mod run_agent_fabro_tools {
use crate::layers::Combine;
use crate::{SettingsLayer, WorkflowSettingsBuilder};
fn parse_settings(source: &str) -> SettingsLayer {
source
.parse::<SettingsLayer>()
.expect("fixture should parse via SettingsLayer")
}
#[test]
fn defaults_to_false_when_run_agent_is_absent() {
let settings = WorkflowSettingsBuilder::from_layer(&SettingsLayer::default())
.expect("empty settings should resolve")
.run;
assert!(!settings.agent.fabro_tools);
}
#[test]
fn resolves_true_from_run_agent_table() {
let settings = WorkflowSettingsBuilder::from_toml(
r"
_version = 1
[run.agent]
fabro_tools = true
",
)
.expect("run.agent.fabro_tools should resolve");
assert!(settings.run.agent.fabro_tools);
}
#[test]
fn resolves_explicit_false_from_run_agent_table() {
let settings = WorkflowSettingsBuilder::from_toml(
r"
_version = 1
[run.agent]
fabro_tools = false
",
)
.expect("run.agent.fabro_tools false should resolve");
assert!(!settings.run.agent.fabro_tools);
}
#[test]
fn higher_layer_false_overrides_lower_true() {
let workflow = parse_settings(
r"
_version = 1
[run.agent]
fabro_tools = false
",
);
let user = parse_settings(
r"
_version = 1
[run.agent]
fabro_tools = true
",
);
let merged = workflow.combine(user);
let settings = WorkflowSettingsBuilder::from_layer(&merged)
.expect("merged settings should resolve")
.run;
assert!(!settings.agent.fabro_tools);
}
}

View file

@ -130,6 +130,7 @@ enabled = true",
Section::of::<fabro_config::RunAgentLayer>(
"[run.agent]",
r#"[run.agent]
fabro_tools = true
permissions = "read-write""#,
),
]

View file

@ -2811,6 +2811,7 @@ fn worker_command(
run_id: RunId,
mode: RunExecutionMode,
run_dir: &std::path::Path,
agent_fabro_tools_enabled: bool,
) -> anyhow::Result<Command> {
let current_exe = std::env::current_exe().context("reading current executable path")?;
let exe = std::env::var_os(EnvVars::CARGO_BIN_EXE_FABRO).map_or(current_exe, PathBuf::from);
@ -2823,12 +2824,13 @@ fn worker_command(
)
})?;
let server_target = daemon.bind.to_target();
let worker_token = issue_worker_token_with_scopes(
state.worker_token_keys(),
&run_id,
WorkerScopeSet::run_worker_with_agent_run_tools(),
)
.map_err(|_| anyhow::anyhow!("failed to sign worker token"))?;
let scopes = if agent_fabro_tools_enabled {
WorkerScopeSet::run_worker_with_agent_run_tools()
} else {
WorkerScopeSet::run_worker()
};
let worker_token = issue_worker_token_with_scopes(state.worker_token_keys(), &run_id, scopes)
.map_err(|_| anyhow::anyhow!("failed to sign worker token"))?;
let server_destination = resolved_log_destination(state)?;
let worker_stdout = match server_destination {
LogDestination::Stdout => Stdio::inherit(),
@ -3103,7 +3105,7 @@ async fn execute_run(state: Arc<AppState>, run_id: RunId) {
return;
}
execute_run_subprocess(state, run_id).await;
Box::pin(execute_run_subprocess(state, run_id)).await;
}
async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
@ -3432,6 +3434,22 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
run_store.subscribe(),
));
let run_state = match run_store.state().await {
Ok(run_state) => run_state,
Err(err) => {
tracing::error!(run_id = %run_id, error = %err, "Failed to load run state");
fail_managed_run(
&state,
run_id,
FailureReason::WorkflowError,
format!("Failed to load run state: {err}"),
);
state.scheduler_notify.notify_one();
return;
}
};
let agent_fabro_tools_enabled = run_state.spec.settings.run.agent.fabro_tools;
let state_for_build = Arc::clone(&state);
let run_dir_for_build = run_dir.clone();
let build_cmd_result = spawn_blocking(move || {
@ -3440,6 +3458,7 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
run_id,
execution_mode,
&run_dir_for_build,
agent_fabro_tools_enabled,
)
})
.await;

View file

@ -1516,92 +1516,53 @@ fn server_secrets_resolve_process_env_before_server_env() {
#[cfg(unix)]
#[test]
fn worker_command_always_sets_worker_token_env() {
let github_only = tempfile::tempdir().unwrap();
let github_state =
worker_command_test_state(github_only.path(), &["github"], Some(TEST_DEV_TOKEN));
let github_run_id = RunId::new();
let github_cmd = worker_command(
github_state.as_ref(),
github_run_id,
RunExecutionMode::Start,
github_only.path(),
)
.unwrap();
assert!(matches!(
command_env_value(&github_cmd, "FABRO_WORKER_TOKEN"),
EnvOverride::Set(_)
));
assert_eq!(
command_env_value(&github_cmd, "FABRO_DEV_TOKEN"),
EnvOverride::Unchanged
);
let github_args = github_cmd
.as_std()
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert!(
!github_args
.iter()
.any(|arg| arg == "--artifact-upload-token")
);
assert!(!github_args.iter().any(|arg| arg == "--worker-token"));
let EnvOverride::Set(github_token) = command_env_value(&github_cmd, "FABRO_WORKER_TOKEN")
else {
panic!("worker token should be set");
};
let github_keys = WorkerTokenKeys::from_master_secret(TEST_SESSION_SECRET.as_bytes())
.expect("worker keys should derive");
let github_claims = jsonwebtoken::decode::<crate::worker_token::WorkerTokenClaims>(
&github_token,
github_keys.decoding_key(),
github_keys.validation(),
)
.expect("github worker token should decode")
.claims;
assert_eq!(github_claims.run_id, github_run_id.to_string());
assert_eq!(
github_claims.scope.split_whitespace().collect::<Vec<_>>(),
vec!["run:worker", "agent:run_tools"]
);
fn worker_command_default_token_omits_agent_run_tools_scope() {
let storage_dir = tempfile::tempdir().unwrap();
let state = worker_command_test_state(storage_dir.path(), &["dev-token"], Some(TEST_DEV_TOKEN));
let run_id = RunId::new();
let dev_token = tempfile::tempdir().unwrap();
let dev_token_state =
worker_command_test_state(dev_token.path(), &["dev-token"], Some(TEST_DEV_TOKEN));
let dev_token_run_id = RunId::new();
let dev_token_cmd = worker_command(
dev_token_state.as_ref(),
dev_token_run_id,
let cmd = worker_command(
state.as_ref(),
run_id,
RunExecutionMode::Start,
dev_token.path(),
storage_dir.path(),
false,
)
.unwrap();
assert!(matches!(
command_env_value(&dev_token_cmd, "FABRO_WORKER_TOKEN"),
EnvOverride::Set(_)
));
assert_eq!(
command_env_value(&dev_token_cmd, "FABRO_DEV_TOKEN"),
EnvOverride::Unchanged
);
let EnvOverride::Set(dev_worker_token) =
command_env_value(&dev_token_cmd, "FABRO_WORKER_TOKEN")
else {
panic!("worker token should be set");
};
let dev_claims = jsonwebtoken::decode::<crate::worker_token::WorkerTokenClaims>(
&dev_worker_token,
github_keys.decoding_key(),
github_keys.validation(),
assert_worker_command_passes_token_only_by_env(&cmd);
let claims = worker_token_claims(&cmd, state.as_ref());
assert_eq!(claims.run_id, run_id.to_string());
assert_eq!(claims.scope.split_whitespace().collect::<Vec<_>>(), vec![
"run:worker"
]);
}
#[cfg(unix)]
#[test]
fn worker_command_opt_in_token_includes_agent_run_tools_scope() {
let storage_dir = tempfile::tempdir().unwrap();
let state = worker_command_test_state(storage_dir.path(), &["dev-token"], Some(TEST_DEV_TOKEN));
let run_id = RunId::new();
let cmd = worker_command(
state.as_ref(),
run_id,
RunExecutionMode::Start,
storage_dir.path(),
true,
)
.expect("dev-token worker token should decode")
.claims;
assert_eq!(dev_claims.run_id, dev_token_run_id.to_string());
assert_eq!(
dev_claims.scope.split_whitespace().collect::<Vec<_>>(),
vec!["run:worker", "agent:run_tools"]
);
.unwrap();
assert_worker_command_passes_token_only_by_env(&cmd);
let claims = worker_token_claims(&cmd, state.as_ref());
assert_eq!(claims.run_id, run_id.to_string());
assert_eq!(claims.scope.split_whitespace().collect::<Vec<_>>(), vec![
"run:worker",
"agent:run_tools"
]);
}
#[cfg(unix)]
@ -1621,6 +1582,7 @@ fn worker_command_forwards_github_app_private_key_from_server_secrets() {
RunId::new(),
RunExecutionMode::Start,
storage_dir.path(),
false,
)
.unwrap();
@ -1640,6 +1602,7 @@ fn worker_command_omits_github_app_private_key_when_unset() {
RunId::new(),
RunExecutionMode::Start,
storage_dir.path(),
false,
)
.unwrap();
@ -1669,6 +1632,7 @@ level = "debug"
run_id,
RunExecutionMode::Start,
storage_dir.path(),
false,
)
.unwrap();
@ -1698,6 +1662,7 @@ destination = "stdout"
run_id,
RunExecutionMode::Start,
storage_dir.path(),
false,
)
.unwrap();
@ -1726,6 +1691,7 @@ fn worker_command_sets_fabro_config_to_active_absolute_config_path() {
run_id,
RunExecutionMode::Start,
storage_dir.path(),
false,
)
.unwrap();
@ -1767,6 +1733,7 @@ destination = "file"
run_id,
RunExecutionMode::Start,
storage_dir.path(),
false,
)
.unwrap();
@ -1798,6 +1765,7 @@ destination = "file"
run_id,
RunExecutionMode::Start,
storage_dir.path(),
false,
) else {
panic!("invalid env destination should fail");
};
@ -2106,6 +2074,40 @@ fn command_env_value(cmd: &Command, key: &str) -> EnvOverride {
.unwrap_or(EnvOverride::Unchanged)
}
#[cfg(unix)]
fn assert_worker_command_passes_token_only_by_env(cmd: &Command) {
assert!(matches!(
command_env_value(cmd, EnvVars::FABRO_WORKER_TOKEN),
EnvOverride::Set(_)
));
assert_eq!(
command_env_value(cmd, EnvVars::FABRO_DEV_TOKEN),
EnvOverride::Unchanged
);
let args = cmd
.as_std()
.get_args()
.map(|arg| arg.to_string_lossy())
.collect::<Vec<_>>();
assert!(!args.iter().any(|arg| arg == "--artifact-upload-token"));
assert!(!args.iter().any(|arg| arg == "--worker-token"));
}
#[cfg(unix)]
fn worker_token_claims(cmd: &Command, state: &AppState) -> crate::worker_token::WorkerTokenClaims {
let EnvOverride::Set(token) = command_env_value(cmd, EnvVars::FABRO_WORKER_TOKEN) else {
panic!("worker token env should be set");
};
jsonwebtoken::decode::<crate::worker_token::WorkerTokenClaims>(
&token,
state.worker_token_keys().decoding_key(),
state.worker_token_keys().validation(),
)
.expect("worker token should decode")
.claims
}
#[tokio::test]
async fn subprocess_answer_transport_cancel_run_enqueues_cancel_message() {
let (control_tx, mut control_rx) = tokio::sync::mpsc::channel(1);

View file

@ -66,7 +66,6 @@ pub(crate) struct WorkerScopeSet {
}
impl WorkerScopeSet {
#[cfg(test)]
#[must_use]
pub(crate) const fn run_worker() -> Self {
Self {

View file

@ -417,10 +417,28 @@ pub struct InterviewProviderSettings {
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct RunAgentSettings {
#[serde(default)]
pub fabro_tools: bool,
pub permissions: Option<AgentPermissions>,
pub mcps: HashMap<String, McpServerSettings>,
}
#[cfg(test)]
mod run_agent_settings_tests {
use super::RunAgentSettings;
#[test]
fn deserializes_missing_fabro_tools_as_false() {
let settings: RunAgentSettings = serde_json::from_value(serde_json::json!({
"permissions": null,
"mcps": {}
}))
.expect("legacy run agent settings should deserialize");
assert!(!settings.fabro_tools);
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct McpServerSettings {
pub name: String,