mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-06 02:48:25 +00:00
## Summary API-backed Fabro agent sessions can now use the same five run-control tools that were previously MCP-only, while the server uses a single scoped `FABRO_WORKER_TOKEN` path for worker authorization. This lets server-dispatched API agents create, gather, inspect, and interact with runs without reintroducing a separate delegated run-agent token or leaking credentials into sandboxed child environments. ## Changes - Moved the reusable run-tool implementation into the new `fabro-tool` crate so MCP and API agent backends share the same tool schemas and client behavior. - Registered the five `fabro_run_*` tools for API-mode agents, with ACP sessions continuing to omit those tools. - Replaced `FABRO_RUN_AGENT_TOKEN` with scoped worker-token auth: base worker tokens keep same-run access, and `run:worker agent:run_tools` tokens can call the run-control API across runs. - Added server auth guards for run-tool actors and run-scoped-or-run-tools routes, then applied them only to the routes used by the run-tool client backend. - Kept `FABRO_WORKER_TOKEN` scrubbed from sandbox commands, hooks, ACP subprocesses, MCP env providers, and other child tool environments. ## Testing - `cargo nextest run -p fabro-server worker_token principal_middleware spawn_env --no-fail-fast` - `cargo nextest run -p fabro-cli runner --no-fail-fast` - `cargo nextest run -p fabro-workflow agent_run --no-fail-fast` - `cargo nextest run -p fabro-static --no-fail-fast` - `cargo +nightly-2026-04-14 fmt --check --all` - `cargo +nightly-2026-04-14 clippy -p fabro-server -p fabro-cli -p fabro-static --all-targets -- -D warnings` - `git diff --check` --- [](https://github.com/EveryInc/compound-engineering-plugin) 🤖 Generated with GPT-5 via [Codex](https://openai.com/codex)
164 lines
6.2 KiB
Rust
164 lines
6.2 KiB
Rust
use std::ffi::OsString;
|
|
|
|
use fabro_static::EnvVars;
|
|
use tokio::process::Command;
|
|
|
|
const WORKER_ENV_ALLOWLIST: &[&str] = &[
|
|
EnvVars::PATH,
|
|
EnvVars::HOME,
|
|
EnvVars::TMPDIR,
|
|
EnvVars::USER,
|
|
EnvVars::RUST_LOG,
|
|
EnvVars::RUST_BACKTRACE,
|
|
EnvVars::FABRO_LOG,
|
|
EnvVars::FABRO_HOME,
|
|
EnvVars::FABRO_STORAGE_ROOT,
|
|
EnvVars::TERM,
|
|
EnvVars::NO_COLOR,
|
|
EnvVars::CLICOLOR,
|
|
EnvVars::CLICOLOR_FORCE,
|
|
];
|
|
|
|
const RENDER_GRAPH_ENV_ALLOWLIST: &[&str] = &[EnvVars::PATH, EnvVars::HOME, EnvVars::TMPDIR];
|
|
|
|
pub(crate) fn apply_worker_env(cmd: &mut Command) {
|
|
apply_allowlist(cmd, WORKER_ENV_ALLOWLIST, &process_env_var_os);
|
|
}
|
|
|
|
pub(crate) fn apply_render_graph_env(cmd: &mut Command) {
|
|
apply_allowlist(cmd, RENDER_GRAPH_ENV_ALLOWLIST, &process_env_var_os);
|
|
}
|
|
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "Subprocess env allowlists intentionally copy a narrow process-env subset."
|
|
)]
|
|
fn process_env_var_os(name: &str) -> Option<OsString> {
|
|
std::env::var_os(name)
|
|
}
|
|
|
|
fn apply_allowlist(cmd: &mut Command, keys: &[&str], lookup: &dyn Fn(&str) -> Option<OsString>) {
|
|
cmd.env_clear();
|
|
for key in keys {
|
|
if let Some(value) = lookup(key) {
|
|
cmd.env(key, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(all(test, unix))]
|
|
mod tests {
|
|
use std::collections::HashMap;
|
|
use std::ffi::OsString;
|
|
use std::path::Path;
|
|
|
|
use super::{RENDER_GRAPH_ENV_ALLOWLIST, WORKER_ENV_ALLOWLIST, apply_allowlist};
|
|
|
|
fn env_command() -> tokio::process::Command {
|
|
assert!(Path::new("/usr/bin/env").exists());
|
|
tokio::process::Command::new("/usr/bin/env")
|
|
}
|
|
|
|
async fn env_output(mut cmd: tokio::process::Command) -> HashMap<String, String> {
|
|
let output = cmd.output().await.expect("running env subprocess");
|
|
assert!(output.status.success());
|
|
String::from_utf8(output.stdout)
|
|
.expect("parsing env subprocess output as UTF-8")
|
|
.lines()
|
|
.filter_map(|line| {
|
|
let (key, value) = line.split_once('=')?;
|
|
Some((key.to_string(), value.to_string()))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn worker_allowlist_is_fail_closed() {
|
|
let env = HashMap::from([
|
|
("PATH".to_string(), "/bin".to_string()),
|
|
("HOME".to_string(), "/tmp/home".to_string()),
|
|
("TMPDIR".to_string(), "/tmp".to_string()),
|
|
("USER".to_string(), "alice".to_string()),
|
|
("RUST_LOG".to_string(), "debug".to_string()),
|
|
("FABRO_LOG".to_string(), "debug".to_string()),
|
|
("FABRO_LOG_DESTINATION".to_string(), "stdout".to_string()),
|
|
("FABRO_HOME".to_string(), "/tmp/fabro-home".to_string()),
|
|
(
|
|
"FABRO_STORAGE_ROOT".to_string(),
|
|
"/tmp/fabro-storage".to_string(),
|
|
),
|
|
("TERM".to_string(), "xterm-256color".to_string()),
|
|
("NO_COLOR".to_string(), "1".to_string()),
|
|
("CLICOLOR".to_string(), "0".to_string()),
|
|
("CLICOLOR_FORCE".to_string(), "1".to_string()),
|
|
("SESSION_SECRET".to_string(), "leak".to_string()),
|
|
("FABRO_JWT_PRIVATE_KEY".to_string(), "leak".to_string()),
|
|
("FABRO_JWT_PUBLIC_KEY".to_string(), "leak".to_string()),
|
|
("GITHUB_APP_PRIVATE_KEY".to_string(), "leak".to_string()),
|
|
("GITHUB_APP_CLIENT_SECRET".to_string(), "leak".to_string()),
|
|
("GITHUB_APP_WEBHOOK_SECRET".to_string(), "leak".to_string()),
|
|
("FABRO_DEV_TOKEN".to_string(), "garbage".to_string()),
|
|
("FABRO_WORKER_TOKEN".to_string(), "leak".to_string()),
|
|
("MY_API_KEY".to_string(), "blocked".to_string()),
|
|
]);
|
|
let mut cmd = env_command();
|
|
apply_allowlist(&mut cmd, WORKER_ENV_ALLOWLIST, &|name| {
|
|
env.get(name).map(OsString::from)
|
|
});
|
|
cmd.env(
|
|
"FABRO_DEV_TOKEN",
|
|
"fabro_dev_abababababababababababababababababababababababababababababababab",
|
|
);
|
|
|
|
let actual = env_output(cmd).await;
|
|
|
|
assert_eq!(actual.get("PATH").map(String::as_str), Some("/bin"));
|
|
assert_eq!(actual.get("HOME").map(String::as_str), Some("/tmp/home"));
|
|
assert_eq!(actual.get("FABRO_LOG").map(String::as_str), Some("debug"));
|
|
assert_eq!(
|
|
actual.get("TERM").map(String::as_str),
|
|
Some("xterm-256color")
|
|
);
|
|
assert_eq!(actual.get("NO_COLOR").map(String::as_str), Some("1"));
|
|
assert_eq!(actual.get("CLICOLOR").map(String::as_str), Some("0"));
|
|
assert_eq!(actual.get("CLICOLOR_FORCE").map(String::as_str), Some("1"));
|
|
assert!(!actual.contains_key("FABRO_LOG_DESTINATION"));
|
|
assert_eq!(
|
|
actual.get("FABRO_DEV_TOKEN").map(String::as_str),
|
|
Some("fabro_dev_abababababababababababababababababababababababababababababababab")
|
|
);
|
|
assert!(!actual.contains_key("SESSION_SECRET"));
|
|
assert!(!actual.contains_key("FABRO_JWT_PRIVATE_KEY"));
|
|
assert!(!actual.contains_key("FABRO_JWT_PUBLIC_KEY"));
|
|
assert!(!actual.contains_key("GITHUB_APP_PRIVATE_KEY"));
|
|
assert!(!actual.contains_key("GITHUB_APP_CLIENT_SECRET"));
|
|
assert!(!actual.contains_key("GITHUB_APP_WEBHOOK_SECRET"));
|
|
assert!(!actual.contains_key("FABRO_WORKER_TOKEN"));
|
|
assert!(!actual.contains_key("MY_API_KEY"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn render_graph_allowlist_is_fail_closed() {
|
|
let env = HashMap::from([
|
|
("PATH".to_string(), "/bin".to_string()),
|
|
("HOME".to_string(), "/tmp/home".to_string()),
|
|
("TMPDIR".to_string(), "/tmp".to_string()),
|
|
("FABRO_TELEMETRY".to_string(), "on".to_string()),
|
|
("SESSION_SECRET".to_string(), "leak".to_string()),
|
|
]);
|
|
let mut cmd = env_command();
|
|
apply_allowlist(&mut cmd, RENDER_GRAPH_ENV_ALLOWLIST, &|name| {
|
|
env.get(name).map(OsString::from)
|
|
});
|
|
cmd.env("FABRO_TELEMETRY", "off");
|
|
|
|
let actual = env_output(cmd).await;
|
|
|
|
assert_eq!(actual.get("PATH").map(String::as_str), Some("/bin"));
|
|
assert_eq!(
|
|
actual.get("FABRO_TELEMETRY").map(String::as_str),
|
|
Some("off")
|
|
);
|
|
assert!(!actual.contains_key("SESSION_SECRET"));
|
|
}
|
|
}
|