refactor(settings): make settings command server-backed

Default `fabro settings` now resolves effective runtime settings against the
selected server, while `--local` preserves local-only inspection. This also
extracts shared settings resolution logic so CLI output, manifest preparation,
and the `/api/v1/settings` contract stay aligned.
This commit is contained in:
Bryan Helmkamp 2026-04-06 13:34:28 -04:00
parent cd392b29c7
commit a740b2d395
No known key found for this signature in database
15 changed files with 971 additions and 108 deletions

View file

@ -5684,6 +5684,76 @@ components:
description: Structured server settings mirroring fabro_types::Settings.
type: object
properties:
version:
type: integer
description: Settings schema version.
goal:
type: string
description: Default goal description.
goal_file:
type: string
description: Path to a goal file.
graph:
type: string
description: Default Graphviz graph path.
labels:
type: object
additionalProperties:
type: string
description: Default label map.
server:
type: object
properties:
target:
type: string
description: Default server target for CLI commands.
tls:
type: object
properties:
cert:
type: string
description: Client certificate path.
key:
type: string
description: Client key path.
ca:
type: string
description: Certificate authority path.
exec:
type: object
properties:
provider:
type: string
description: Default exec provider.
model:
type: string
description: Default exec model.
permissions:
type: string
enum: [read-only, read-write, full]
description: Exec permission level.
output_format:
type: string
enum: [text, json]
description: Exec output format.
prevent_idle_sleep:
type: boolean
description: Prevent system idle sleep while running.
verbose:
type: boolean
description: Enable verbose output by default.
upgrade_check:
type: boolean
description: Whether upgrade checks are enabled.
dry_run:
type: boolean
description: Default dry-run mode.
auto_approve:
type: boolean
description: Default auto-approve mode.
no_retro:
type: boolean
description: Skip retro generation by default.
storage_dir:
type: string
description: Storage directory path.
@ -5731,6 +5801,12 @@ components:
description: Default MCP server configurations.
github:
$ref: "#/components/schemas/GitHubSettings"
fabro:
type: object
properties:
root:
type: string
description: Project fabro root directory.
GitHubSettings:
description: GitHub App token injection configuration.

View file

@ -613,7 +613,11 @@ pub(crate) struct DfArgs {
#[derive(Args)]
pub(crate) struct SettingsArgs {
#[command(flatten)]
pub(crate) storage_dir: StorageDirArgs,
pub(crate) target: ServerTargetArgs,
/// Show only locally resolved settings and skip the server call
#[arg(long, conflicts_with = "server")]
pub(crate) local: bool,
/// Optional workflow name, .fabro path, or .toml run config to overlay
pub(crate) workflow: Option<PathBuf>,
@ -921,7 +925,7 @@ pub(crate) enum Commands {
Skill(SkillNamespace),
/// Manage server-owned secrets
Secret(SecretNamespace),
/// Inspect merged configuration
/// Inspect effective settings
Settings(SettingsArgs),
/// Workflow operations
Workflow(WorkflowNamespace),

View file

@ -2,28 +2,84 @@ use std::io::Write;
use std::path::Path;
use crate::args::{GlobalArgs, SettingsArgs};
use crate::server_client;
use crate::shared::print_json_pretty;
use crate::user_config;
use fabro_config::ConfigLayer;
use fabro_config::effective_settings;
use fabro_config::effective_settings::{EffectiveSettingsLayers, EffectiveSettingsMode};
use fabro_config::project;
use fabro_types::Settings;
fn merged_config(workflow: Option<&Path>, args: &SettingsArgs) -> anyhow::Result<Settings> {
fn config_layers(workflow: Option<&Path>) -> anyhow::Result<EffectiveSettingsLayers> {
let cwd = std::env::current_dir()?;
let base = match workflow {
Some(path) => ConfigLayer::for_workflow(path, &cwd)?,
None => ConfigLayer::project(&cwd)?,
let (workflow_layer, project_layer) = match workflow {
Some(path) => workflow_and_project_layers(path, &cwd)?,
None => (ConfigLayer::default(), ConfigLayer::project(&cwd)?),
};
let cli = user_config::settings_layer_with_storage_dir(args.storage_dir.as_deref())?;
base.combine(cli).resolve()
let user_layer = user_config::settings_layer_with_storage_dir(None)?;
Ok(EffectiveSettingsLayers::new(
ConfigLayer::default(),
workflow_layer,
project_layer,
user_layer,
))
}
pub(crate) fn execute(args: &SettingsArgs, globals: &GlobalArgs) -> anyhow::Result<()> {
let config = merged_config(args.workflow.as_deref(), args)?;
fn workflow_and_project_layers(
path: &Path,
cwd: &Path,
) -> anyhow::Result<(ConfigLayer, ConfigLayer)> {
let resolution = project::resolve_workflow_path(path, cwd)?;
if resolution.workflow_config.is_none() && !resolution.resolved_workflow_path.is_file() {
anyhow::bail!(
"Workflow not found: {}",
resolution.resolved_workflow_path.display()
);
}
let workflow_layer = resolution.workflow_config.unwrap_or_default();
let project_layer = project::discover_project_config(
resolution
.resolved_workflow_path
.parent()
.unwrap_or_else(|| Path::new(".")),
)?
.map(|(_, config)| config)
.unwrap_or_default();
Ok((workflow_layer, project_layer))
}
async fn merged_config(args: &SettingsArgs) -> anyhow::Result<Settings> {
let layers = config_layers(args.workflow.as_deref())?;
if args.local {
return effective_settings::resolve_settings(
layers,
None,
EffectiveSettingsMode::LocalOnly,
);
}
let machine_settings = user_config::load_settings()?;
let target = user_config::resolve_server_target(&args.target, &machine_settings)?;
let client = server_client::connect_server_only(&args.target).await?;
let server_settings = client.retrieve_server_settings().await?;
let mode = match target {
user_config::ServerTarget::HttpUrl { .. } => EffectiveSettingsMode::RemoteServer,
user_config::ServerTarget::UnixSocket(_) => EffectiveSettingsMode::LocalDaemon,
};
effective_settings::resolve_settings(layers, Some(&server_settings), mode)
}
pub(crate) async fn execute(args: &SettingsArgs, globals: &GlobalArgs) -> anyhow::Result<()> {
let config = merged_config(args).await?;
if globals.json {
print_json_pretty(&config)?;
return Ok(());
}
let mut yaml = serde_yaml::to_string(&config)?;
if !yaml.ends_with('\n') {
yaml.push('\n');

View file

@ -225,7 +225,7 @@ async fn main_inner() -> (String, Result<()>) {
}
Commands::Pr(ns) => Box::pin(commands::pr::dispatch(ns, &globals)).await?,
Commands::Secret(ns) => commands::secret::dispatch(ns, &globals).await?,
Commands::Settings(args) => commands::config::execute(&args, &globals)?,
Commands::Settings(args) => commands::config::execute(&args, &globals).await?,
Commands::Workflow(ns) => commands::workflow::dispatch(ns, &globals)?,
Commands::Skill(ns) => commands::skill::dispatch(ns, &globals)?,
Commands::Upgrade(args) => {
@ -534,6 +534,8 @@ mod tests {
assert_eq!(cli.command.name(), "settings");
match *cli.command {
Commands::Settings(args) => {
assert!(!args.local);
assert!(args.target.server.is_none());
assert!(args.workflow.is_none());
}
_ => panic!("unexpected command variant"),
@ -551,6 +553,19 @@ mod tests {
}
}
#[test]
fn parse_settings_local_mode() {
let cli =
Cli::try_parse_from(["fabro", "settings", "--local", "demo"]).expect("should parse");
match *cli.command {
Commands::Settings(args) => {
assert!(args.local);
assert_eq!(args.workflow, Some(std::path::PathBuf::from("demo")));
}
_ => panic!("unexpected command variant"),
}
}
#[test]
fn parse_quiet_flag() {
let cli = Cli::try_parse_from(["fabro", "--quiet", "settings"]).expect("should parse");

View file

@ -9,7 +9,7 @@ use fabro_server::bind::Bind;
use fabro_store::{EventEnvelope, RunSummary, StageId};
use fabro_types::{
Checkpoint, Conclusion, NodeStatusRecord, PullRequestRecord, Retro, RunEvent, RunId, RunRecord,
RunStatusRecord, SandboxRecord, StartRecord,
RunStatusRecord, SandboxRecord, Settings, StartRecord,
};
use futures::StreamExt;
use serde::de::DeserializeOwned;
@ -233,6 +233,16 @@ impl ServerStoreClient {
self.clone()
}
pub(crate) async fn retrieve_server_settings(&self) -> Result<Settings> {
let response = self
.client
.retrieve_server_settings()
.send()
.await
.map_err(map_api_error)?;
convert_type(response.into_inner())
}
pub(crate) async fn create_run_from_manifest(
&self,
manifest: types::RunManifest,

View file

@ -3,6 +3,7 @@ use std::path::PathBuf;
use fabro_config::mcp::McpTransport;
use fabro_test::{fabro_snapshot, test_context};
use fabro_types::Settings;
use httpmock::MockServer;
use predicates::prelude::*;
use super::support::run_state;
@ -17,7 +18,7 @@ fn help() {
success: true
exit_code: 0
----- stdout -----
Inspect merged configuration
Inspect effective settings
Usage: fabro settings [OPTIONS] [WORKFLOW]
@ -25,13 +26,14 @@ fn help() {
[WORKFLOW] Optional workflow name, .fabro path, or .toml run config to overlay
Options:
--json Output as JSON [env: FABRO_JSON=]
--storage-dir <STORAGE_DIR> Local storage directory (default: ~/.fabro/storage) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
-h, --help Print help
--json Output as JSON [env: FABRO_JSON=]
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--local Show only locally resolved settings and skip the server call
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
-h, --help Print help
----- stderr -----
");
}
@ -62,6 +64,47 @@ fn parse_settings(stdout: &[u8]) -> Settings {
serde_yaml::from_slice(stdout).expect("stdout should be valid YAML Settings")
}
fn server_settings_fixture() -> Settings {
toml::from_str(
r#"
storage_dir = "/srv/fabro-server"
verbose = false
[llm]
model = "server-model"
provider = "openai"
[vars]
server_only = "1"
shared = "server"
"#,
)
.expect("server settings fixture should parse")
}
fn server_settings_body(settings: &Settings) -> String {
fn strip_nulls(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(map) => {
for child in map.values_mut() {
strip_nulls(child);
}
map.retain(|_, child| !child.is_null());
}
serde_json::Value::Array(values) => {
for child in values {
strip_nulls(child);
}
}
_ => {}
}
}
let mut value = serde_json::to_value(settings).expect("settings fixture should serialize");
strip_nulls(&mut value);
serde_json::to_string(&value).expect("settings payload should serialize")
}
/// Set up home config and project config for settings command tests.
/// Uses `context.home_dir` for the home directory. Returns project tempdir.
fn setup_settings_fixture(context: &fabro_test::TestContext) -> tempfile::TempDir {
@ -255,12 +298,13 @@ commands = ["workflow-setup"]
// ---------------------------------------------------------------------------
#[test]
fn settings_merges_cli_and_project_defaults() {
fn settings_local_merges_cli_and_project_defaults() {
let context = test_context!();
let project = setup_settings_fixture(&context);
let output = context
.settings()
.arg("--local")
.current_dir(project.path())
.assert()
.success()
@ -291,14 +335,14 @@ fn settings_merges_cli_and_project_defaults() {
}
#[test]
fn settings_workflow_name_applies_run_overlay_and_deep_merges() {
fn settings_local_workflow_name_applies_run_overlay_and_deep_merges() {
let context = test_context!();
let project = setup_settings_fixture(&context);
let output = context
.settings()
.current_dir(project.path())
.args(["demo"])
.args(["--local", "demo"])
.assert()
.success()
.get_output()
@ -367,7 +411,7 @@ fn settings_workflow_name_applies_run_overlay_and_deep_merges() {
}
#[test]
fn settings_explicit_workflow_path_uses_workflow_project_layers() {
fn settings_local_explicit_workflow_path_uses_workflow_project_layers() {
let mut context = test_context!();
let (project, _storage_dir) = setup_external_workflow_fixture(&mut context);
let cwd = tempfile::tempdir().unwrap();
@ -378,7 +422,7 @@ fn settings_explicit_workflow_path_uses_workflow_project_layers() {
.settings()
.env_remove("FABRO_STORAGE_DIR")
.current_dir(cwd.path())
.args([workflow.to_str().unwrap()])
.args(["--local", workflow.to_str().unwrap()])
.assert()
.success()
.get_output()
@ -473,6 +517,7 @@ fn settings_fabro_path_matches_ambient_defaults() {
let ambient = context
.settings()
.arg("--local")
.current_dir(project.path())
.assert()
.success()
@ -482,7 +527,7 @@ fn settings_fabro_path_matches_ambient_defaults() {
let graph = context
.settings()
.current_dir(project.path())
.args(["standalone.fabro"])
.args(["--local", "standalone.fabro"])
.assert()
.success()
.get_output()
@ -499,7 +544,7 @@ fn settings_missing_run_config_errors() {
let mut cmd = context.settings();
cmd.current_dir(project.path());
cmd.args(["missing.toml"]);
cmd.args(["--local", "missing.toml"]);
let output = cmd.output().expect("command should execute");
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stdout).trim().is_empty());
@ -531,6 +576,7 @@ model = "legacy-model"
let assert = context
.settings()
.arg("--local")
.current_dir(project.path())
.assert()
.success()
@ -559,6 +605,7 @@ shared = "legacy"
let assert = context
.settings()
.arg("--local")
.current_dir(project.path())
.assert()
.success()
@ -593,7 +640,7 @@ model = "from-fabro-home"
let output = context
.settings()
.arg("--json")
.args(["--local", "--json"])
.env("FABRO_HOME", fabro_home.path())
.env_remove("FABRO_STORAGE_DIR")
.output()
@ -623,3 +670,147 @@ fn settings_rejects_server_url_flag() {
"unexpected argument '--server-url' found",
));
}
#[test]
fn settings_rejects_storage_dir_flag() {
let context = test_context!();
context
.settings()
.args(["--storage-dir", "/tmp/fabro-settings"])
.assert()
.failure()
.stderr(predicate::str::contains(
"unexpected argument '--storage-dir' found",
));
}
#[test]
fn settings_rejects_local_and_server_combination() {
let context = test_context!();
context
.settings()
.args(["--local", "--server", "https://cli.example.com"])
.assert()
.failure()
.stderr(predicate::str::contains(
"the argument '--local' cannot be used with '--server <SERVER>'",
));
}
#[test]
fn settings_fetches_server_settings_and_merges_with_local_config() {
let context = test_context!();
let project = setup_settings_fixture(&context);
let server = MockServer::start();
let server_settings = server_settings_fixture();
let mock = server.mock(|when, then| {
when.method("GET").path("/api/v1/settings");
then.status(200)
.header("Content-Type", "application/json")
.body(server_settings_body(&server_settings));
});
context.write_home(
".fabro/settings.toml",
format!(
r#"
server = {{ target = "{}/api/v1" }}
verbose = true
[llm]
model = "cli-model"
provider = "openai"
[vars]
cli_only = "1"
shared = "cli"
"#,
server.base_url()
),
);
let output = context
.settings()
.current_dir(project.path())
.assert()
.success()
.get_output()
.stdout
.clone();
mock.assert();
let cfg = parse_settings(&output);
let llm = cfg.llm.as_ref().expect("llm config");
assert_eq!(llm.model.as_deref(), Some("project-model"));
assert_eq!(llm.provider.as_deref(), Some("openai"));
assert_eq!(cfg.storage_dir, Some(PathBuf::from("/srv/fabro-server")));
assert_eq!(cfg.verbose, Some(true));
let vars = cfg.vars.as_ref().expect("vars");
assert_eq!(vars.get("server_only").map(String::as_str), Some("1"));
assert_eq!(vars.get("project_only").map(String::as_str), Some("1"));
assert_eq!(vars.get("shared").map(String::as_str), Some("project"));
}
#[test]
fn settings_cli_server_target_overrides_configured_server_target() {
let context = test_context!();
let project = setup_settings_fixture(&context);
let configured_server = MockServer::start();
let configured_mock = configured_server.mock(|when, then| {
when.method("GET").path("/api/v1/settings");
then.status(500)
.body("configured-server-should-not-be-used");
});
let cli_server = MockServer::start();
let cli_server_settings = server_settings_fixture();
let cli_mock = cli_server.mock(|when, then| {
when.method("GET").path("/api/v1/settings");
then.status(200)
.header("Content-Type", "application/json")
.body(server_settings_body(&cli_server_settings));
});
context.write_home(
".fabro/settings.toml",
format!(
r#"
[server]
target = "{}/api/v1"
verbose = true
"#,
configured_server.base_url()
),
);
let output = context
.settings()
.current_dir(project.path())
.args(["--server", &format!("{}/api/v1", cli_server.base_url())])
.assert()
.success()
.get_output()
.stdout
.clone();
cli_mock.assert();
configured_mock.assert_calls(0);
let cfg = parse_settings(&output);
assert_eq!(cfg.storage_dir, Some(PathBuf::from("/srv/fabro-server")));
}
#[test]
fn settings_unreachable_http_target_fails_clearly() {
let context = test_context!();
let project = setup_settings_fixture(&context);
context
.settings()
.current_dir(project.path())
.args(["--server", "http://127.0.0.1:9"])
.assert()
.failure()
.stderr(
predicate::str::contains("retrieve_server_settings")
.or(predicate::str::contains("error sending request")),
);
}

View file

@ -34,7 +34,7 @@ fn help() {
install Set up the Fabro environment (LLMs, certs, GitHub)
pr Pull request operations
secret Manage server-owned secrets
settings Inspect merged configuration
settings Inspect effective settings
workflow Workflow operations
discord Open the Discord community in the browser
docs Open the docs website in the browser

View file

@ -24,11 +24,11 @@ fabro-types = { path = "../fabro-types" }
fabro-util = { path = "../fabro-util" }
dirs.workspace = true
serde.workspace = true
serde_json.workspace = true
strsim = "0.11"
toml.workspace = true
tracing.workspace = true
[dev-dependencies]
serde_json.workspace = true
tempfile = "3"
toml.workspace = true

View file

@ -0,0 +1,437 @@
use anyhow::{Result, anyhow};
use fabro_types::Settings;
use crate::ConfigLayer;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EffectiveSettingsMode {
LocalOnly,
RemoteServer,
LocalDaemon,
}
#[derive(Clone, Debug, Default)]
pub struct EffectiveSettingsLayers {
pub args: ConfigLayer,
pub workflow: ConfigLayer,
pub project: ConfigLayer,
pub user: ConfigLayer,
}
impl EffectiveSettingsLayers {
#[must_use]
pub fn new(
args: ConfigLayer,
workflow: ConfigLayer,
project: ConfigLayer,
user: ConfigLayer,
) -> Self {
Self {
args,
workflow,
project,
user,
}
}
}
pub fn resolve_settings(
layers: EffectiveSettingsLayers,
server_settings: Option<&Settings>,
mode: EffectiveSettingsMode,
) -> Result<Settings> {
let EffectiveSettingsLayers {
args,
mut workflow,
mut project,
mut user,
} = layers;
match mode {
EffectiveSettingsMode::LocalOnly => args
.combine(workflow)
.combine(project)
.combine(user)
.resolve(),
EffectiveSettingsMode::RemoteServer | EffectiveSettingsMode::LocalDaemon => {
let server_settings = server_settings.ok_or_else(|| {
anyhow!("server settings are required for server-targeted settings resolution")
})?;
strip_server_owned_fields(&mut workflow);
strip_server_owned_fields(&mut project);
strip_server_owned_fields(&mut user);
let server_defaults = match mode {
EffectiveSettingsMode::RemoteServer => server_defaults_layer(server_settings)?,
EffectiveSettingsMode::LocalDaemon => {
local_daemon_server_overrides_layer(server_settings)?
}
EffectiveSettingsMode::LocalOnly => unreachable!(),
};
let mut settings = args
.combine(workflow)
.combine(project)
.combine(user)
.combine(server_defaults)
.resolve()?;
settings
.storage_dir
.clone_from(&server_settings.storage_dir);
Ok(settings)
}
}
}
fn server_defaults_layer(settings: &Settings) -> Result<ConfigLayer> {
let mut layer: ConfigLayer = serde_json::from_value(serde_json::to_value(settings)?)?;
// Run manifests carry their own dry-run intent. Do not let a daemon's
// startup-time fallback mode silently force every submitted run/preflight
// into simulation.
layer.dry_run = None;
Ok(layer)
}
fn local_daemon_server_overrides_layer(settings: &Settings) -> Result<ConfigLayer> {
let layer = server_defaults_layer(settings)?;
Ok(ConfigLayer {
storage_dir: layer.storage_dir,
max_concurrent_runs: layer.max_concurrent_runs,
web: layer.web,
api: layer.api,
features: layer.features,
..Default::default()
})
}
fn strip_server_owned_fields(layer: &mut ConfigLayer) {
layer.server = None;
layer.exec = None;
layer.storage_dir = None;
layer.max_concurrent_runs = None;
layer.web = None;
layer.api = None;
layer.features = None;
layer.log = None;
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::{EffectiveSettingsLayers, EffectiveSettingsMode, resolve_settings};
use crate::ConfigLayer;
fn layer(source: &str) -> ConfigLayer {
toml::from_str(source).expect("config layer fixture should parse")
}
#[test]
fn local_only_merges_project_and_user_layers() {
let settings = resolve_settings(
EffectiveSettingsLayers::new(
ConfigLayer::default(),
ConfigLayer::default(),
layer(
r#"
[llm]
model = "project-model"
[vars]
project_only = "1"
shared = "project"
"#,
),
layer(
r#"
storage_dir = "/tmp/local-storage"
[llm]
provider = "openai"
[vars]
user_only = "1"
shared = "user"
"#,
),
),
None,
EffectiveSettingsMode::LocalOnly,
)
.unwrap();
let llm = settings.llm.expect("llm config");
assert_eq!(llm.model.as_deref(), Some("project-model"));
assert_eq!(llm.provider.as_deref(), Some("openai"));
assert_eq!(
settings.storage_dir,
Some(PathBuf::from("/tmp/local-storage"))
);
assert_eq!(
settings
.vars
.as_ref()
.and_then(|vars| vars.get("project_only")),
Some(&"1".to_string())
);
assert_eq!(
settings
.vars
.as_ref()
.and_then(|vars| vars.get("user_only")),
Some(&"1".to_string())
);
assert_eq!(
settings.vars.as_ref().and_then(|vars| vars.get("shared")),
Some(&"project".to_string())
);
}
#[test]
fn local_only_merges_workflow_project_and_user_layers() {
let settings = resolve_settings(
EffectiveSettingsLayers::new(
ConfigLayer::default(),
layer(
r#"
goal = "workflow goal"
[llm]
model = "workflow-model"
[vars]
workflow_only = "1"
shared = "workflow"
"#,
),
layer(
r#"
[llm]
model = "project-model"
[vars]
project_only = "1"
shared = "project"
"#,
),
layer(
r#"
[llm]
provider = "openai"
[vars]
user_only = "1"
shared = "user"
"#,
),
),
None,
EffectiveSettingsMode::LocalOnly,
)
.unwrap();
let llm = settings.llm.expect("llm config");
assert_eq!(settings.goal.as_deref(), Some("workflow goal"));
assert_eq!(llm.model.as_deref(), Some("workflow-model"));
assert_eq!(llm.provider.as_deref(), Some("openai"));
assert_eq!(
settings
.vars
.as_ref()
.and_then(|vars| vars.get("workflow_only")),
Some(&"1".to_string())
);
assert_eq!(
settings
.vars
.as_ref()
.and_then(|vars| vars.get("project_only")),
Some(&"1".to_string())
);
assert_eq!(
settings
.vars
.as_ref()
.and_then(|vars| vars.get("user_only")),
Some(&"1".to_string())
);
assert_eq!(
settings.vars.as_ref().and_then(|vars| vars.get("shared")),
Some(&"workflow".to_string())
);
}
#[test]
fn remote_server_mode_merges_server_defaults_without_allowing_server_owned_local_overrides() {
let server_settings: fabro_types::Settings = toml::from_str(
r#"
storage_dir = "/srv/fabro"
max_concurrent_runs = 9
dry_run = true
[vars]
server_only = "1"
shared = "server"
"#,
)
.unwrap();
let settings = resolve_settings(
EffectiveSettingsLayers::new(
ConfigLayer::default(),
ConfigLayer::default(),
layer(
r#"
storage_dir = "/tmp/local-storage"
max_concurrent_runs = 3
[vars]
project_only = "1"
shared = "project"
"#,
),
ConfigLayer::default(),
),
Some(&server_settings),
EffectiveSettingsMode::RemoteServer,
)
.unwrap();
assert_eq!(settings.storage_dir, Some(PathBuf::from("/srv/fabro")));
assert_eq!(settings.max_concurrent_runs, Some(9));
assert_eq!(settings.dry_run, None);
assert_eq!(
settings
.vars
.as_ref()
.and_then(|vars| vars.get("server_only")),
Some(&"1".to_string())
);
assert_eq!(
settings
.vars
.as_ref()
.and_then(|vars| vars.get("project_only")),
Some(&"1".to_string())
);
assert_eq!(
settings.vars.as_ref().and_then(|vars| vars.get("shared")),
Some(&"project".to_string())
);
}
#[test]
fn remote_server_mode_merges_workflow_project_user_and_server_layers() {
let server_settings: fabro_types::Settings = toml::from_str(
r#"
storage_dir = "/srv/fabro"
[vars]
server_only = "1"
shared = "server"
"#,
)
.unwrap();
let settings = resolve_settings(
EffectiveSettingsLayers::new(
ConfigLayer::default(),
layer(
r#"
[llm]
model = "workflow-model"
[vars]
workflow_only = "1"
shared = "workflow"
"#,
),
layer(
r#"
[vars]
project_only = "1"
shared = "project"
"#,
),
layer(
r#"
[llm]
provider = "openai"
[vars]
user_only = "1"
"#,
),
),
Some(&server_settings),
EffectiveSettingsMode::RemoteServer,
)
.unwrap();
let llm = settings.llm.expect("llm config");
assert_eq!(llm.model.as_deref(), Some("workflow-model"));
assert_eq!(llm.provider.as_deref(), Some("openai"));
assert_eq!(settings.storage_dir, Some(PathBuf::from("/srv/fabro")));
assert_eq!(
settings
.vars
.as_ref()
.and_then(|vars| vars.get("workflow_only")),
Some(&"1".to_string())
);
assert_eq!(
settings
.vars
.as_ref()
.and_then(|vars| vars.get("project_only")),
Some(&"1".to_string())
);
assert_eq!(
settings
.vars
.as_ref()
.and_then(|vars| vars.get("user_only")),
Some(&"1".to_string())
);
assert_eq!(
settings
.vars
.as_ref()
.and_then(|vars| vars.get("server_only")),
Some(&"1".to_string())
);
assert_eq!(
settings.vars.as_ref().and_then(|vars| vars.get("shared")),
Some(&"workflow".to_string())
);
}
#[test]
fn local_daemon_mode_only_applies_server_owned_overrides() {
let server_settings: fabro_types::Settings = toml::from_str(
r#"
storage_dir = "/srv/fabro"
max_concurrent_runs = 7
[llm]
model = "server-model"
[vars]
server_only = "1"
"#,
)
.unwrap();
let settings = resolve_settings(
EffectiveSettingsLayers::default(),
Some(&server_settings),
EffectiveSettingsMode::LocalDaemon,
)
.unwrap();
assert_eq!(settings.storage_dir, Some(PathBuf::from("/srv/fabro")));
assert_eq!(settings.max_concurrent_runs, Some(7));
assert_eq!(settings.llm, None);
assert_eq!(settings.vars, None);
}
}

View file

@ -2,6 +2,7 @@ extern crate self as fabro_config;
pub mod combine;
pub mod config;
pub mod effective_settings;
pub mod home;
pub mod hook;
pub mod legacy_env;

View file

@ -5,6 +5,8 @@ use std::sync::Arc;
use anyhow::{Result, anyhow, bail};
use fabro_api::types;
use fabro_config::ConfigLayer;
use fabro_config::effective_settings;
use fabro_config::effective_settings::{EffectiveSettingsLayers, EffectiveSettingsMode};
use fabro_config::project::resolve_working_directory;
use fabro_config::run::{LlmConfig, parse_run_config};
use fabro_config::sandbox::{DockerfileSource, SandboxConfig};
@ -71,21 +73,15 @@ pub(crate) fn prepare_manifest_with_mode(
.try_fold(ConfigLayer::default(), |layer, config| {
Ok::<_, anyhow::Error>(parse_manifest_config(config)?.combine(layer))
})?;
let server_defaults = if local_daemon_mode {
local_daemon_server_overrides_layer(server_settings)?
} else {
server_defaults_layer(server_settings)?
};
let mut settings = args_layer
.combine(workflow_layer)
.combine(project_layer)
.combine(user_layer)
.combine(server_defaults)
.resolve()?;
settings
.storage_dir
.clone_from(&server_settings.storage_dir);
let mut settings = effective_settings::resolve_settings(
EffectiveSettingsLayers::new(args_layer, workflow_layer, project_layer, user_layer),
Some(server_settings),
if local_daemon_mode {
EffectiveSettingsMode::LocalDaemon
} else {
EffectiveSettingsMode::RemoteServer
},
)?;
if let Some(goal) = manifest.goal.as_ref() {
settings.goal = Some(goal.text.clone());
settings.goal_file = None;
@ -195,7 +191,6 @@ fn root_workflow_config_layer(
let mut layer = parse_run_config(&config.source)?;
resolve_manifest_dockerfile(&mut layer, Path::new(&config.path), &workflow.files)?;
strip_server_owned_fields(&mut layer);
Ok(layer)
}
@ -203,66 +198,7 @@ fn parse_manifest_config(config: &types::ManifestConfig) -> Result<ConfigLayer>
let Some(source) = config.source.as_deref() else {
return Ok(ConfigLayer::default());
};
let mut layer: ConfigLayer = toml::from_str(source)?;
strip_server_owned_fields(&mut layer);
Ok(layer)
}
fn resolve_manifest_dockerfile(
layer: &mut ConfigLayer,
config_path: &Path,
files: &HashMap<PathBuf, String>,
) -> Result<()> {
let source = layer
.sandbox
.as_mut()
.and_then(|sandbox| sandbox.daytona.as_mut())
.and_then(|daytona| daytona.snapshot.as_mut())
.and_then(|snapshot| snapshot.dockerfile.as_mut());
let Some(DockerfileSource::Path { path }) = source else {
return Ok(());
};
let logical_path =
normalize_logical_path(config_path.parent().unwrap_or_else(|| Path::new(".")), path)
.ok_or_else(|| anyhow!("unsupported dockerfile reference: {path}"))?;
let content = files
.get(&logical_path)
.cloned()
.ok_or_else(|| anyhow!("missing bundled dockerfile: {}", logical_path.display()))?;
*source.unwrap() = DockerfileSource::Inline(content);
Ok(())
}
fn server_defaults_layer(settings: &Settings) -> Result<ConfigLayer> {
let mut layer: ConfigLayer = serde_json::from_value(serde_json::to_value(settings)?)?;
// Run manifests carry their own dry-run intent. Do not let a daemon's
// startup-time fallback mode silently force every submitted run/preflight
// into simulation.
layer.dry_run = None;
Ok(layer)
}
fn local_daemon_server_overrides_layer(settings: &Settings) -> Result<ConfigLayer> {
let layer = server_defaults_layer(settings)?;
Ok(ConfigLayer {
storage_dir: layer.storage_dir,
max_concurrent_runs: layer.max_concurrent_runs,
web: layer.web,
api: layer.api,
features: layer.features,
..Default::default()
})
}
fn strip_server_owned_fields(layer: &mut ConfigLayer) {
layer.server = None;
layer.exec = None;
layer.storage_dir = None;
layer.max_concurrent_runs = None;
layer.web = None;
layer.api = None;
layer.features = None;
layer.log = None;
toml::from_str(source).map_err(Into::into)
}
fn manifest_args_layer(args: Option<&types::ManifestArgs>) -> ConfigLayer {
@ -302,6 +238,31 @@ fn parse_labels(labels: &[String]) -> HashMap<String, String> {
.collect()
}
fn resolve_manifest_dockerfile(
layer: &mut ConfigLayer,
config_path: &Path,
files: &HashMap<PathBuf, String>,
) -> Result<()> {
let source = layer
.sandbox
.as_mut()
.and_then(|sandbox| sandbox.daytona.as_mut())
.and_then(|daytona| daytona.snapshot.as_mut())
.and_then(|snapshot| snapshot.dockerfile.as_mut());
let Some(DockerfileSource::Path { path }) = source else {
return Ok(());
};
let logical_path =
normalize_logical_path(config_path.parent().unwrap_or_else(|| Path::new(".")), path)
.ok_or_else(|| anyhow!("unsupported dockerfile reference: {path}"))?;
let content = files
.get(&logical_path)
.cloned()
.ok_or_else(|| anyhow!("missing bundled dockerfile: {}", logical_path.display()))?;
*source.unwrap() = DockerfileSource::Inline(content);
Ok(())
}
fn normalize_logical_path(current_dir: &Path, reference: &str) -> Option<PathBuf> {
let path = Path::new(reference);
if path.is_absolute() || reference.starts_with('~') {

View file

@ -557,7 +557,7 @@ fn real_routes() -> Router<Arc<AppState>> {
.route("/repos/github/{owner}/{name}", get(get_github_repo))
.route("/health/diagnostics", post(run_diagnostics))
.route("/completions", post(create_completion))
.route("/settings", get(not_implemented))
.route("/settings", get(get_server_settings))
.route("/usage", get(get_aggregate_usage))
}
@ -573,6 +573,44 @@ async fn health() -> Response {
.into_response()
}
async fn get_server_settings(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
) -> Response {
let settings = state.settings.read().unwrap().clone();
let response = match api_server_settings(&settings) {
Ok(response) => response,
Err(err) => {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
};
(StatusCode::OK, Json(response)).into_response()
}
fn api_server_settings(settings: &Settings) -> anyhow::Result<fabro_api::types::ServerSettings> {
let mut value = serde_json::to_value(settings)?;
strip_nulls(&mut value);
serde_json::from_value(value).map_err(Into::into)
}
fn strip_nulls(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(map) => {
for child in map.values_mut() {
strip_nulls(child);
}
map.retain(|_, child| !child.is_null());
}
serde_json::Value::Array(values) => {
for child in values {
strip_nulls(child);
}
}
_ => {}
}
}
async fn list_secrets(_auth: AuthenticatedService, State(state): State<Arc<AppState>>) -> Response {
let data = state.secret_store.read().await.list();
(StatusCode::OK, Json(serde_json::json!({ "data": data }))).into_response()

View file

@ -1,3 +1,4 @@
#[cfg(target_os = "linux")]
mod mtls;
mod routing;
mod settings;

View file

@ -0,0 +1,41 @@
use axum::body::Body;
use axum::http::{Request, StatusCode};
use fabro_server::jwt_auth::AuthMode;
use fabro_server::server::{build_router, create_app_state_with_options};
use fabro_types::Settings;
use tower::ServiceExt;
use crate::helpers::body_json;
#[tokio::test]
async fn retrieve_server_settings_returns_runtime_settings() {
let settings: Settings = toml::from_str(
r#"
storage_dir = "/srv/fabro"
max_concurrent_runs = 9
verbose = true
[vars]
server_only = "1"
"#,
)
.expect("settings fixture should parse");
let app = build_router(
create_app_state_with_options(settings, 5),
AuthMode::Disabled,
);
let request = Request::builder()
.method("GET")
.uri("/api/v1/settings")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
assert_eq!(body["storage_dir"], "/srv/fabro");
assert_eq!(body["max_concurrent_runs"], 9);
assert_eq!(body["verbose"], true);
assert_eq!(body["vars"]["server_only"], "1");
}

View file

@ -19,6 +19,10 @@ use fabro_sandbox::daytona::*;
use fabro_server::jwt_auth::AuthMode;
use fabro_server::server::build_router;
use fabro_server::server_config::*;
use fabro_types::settings::{
ClientTlsSettings, ExecSettings, OutputFormat, PermissionLevel, ProjectSettings,
ServerSettings as UserServerSettings,
};
use tower::ServiceExt;
fn load_spec() -> openapiv3::OpenAPI {
@ -230,6 +234,31 @@ fn compare_schema(
/// in the serialized JSON.
fn fully_populated_server_config() -> Settings {
Settings {
version: Some(1),
goal: Some("default goal".into()),
goal_file: Some("/tmp/goal.txt".into()),
graph: Some("workflow.fabro".into()),
labels: std::collections::HashMap::from([("scope".into(), "server".into())]),
server: Some(UserServerSettings {
target: Some("https://server.example.com".into()),
tls: Some(ClientTlsSettings {
cert: "client-cert.pem".into(),
key: "client-key.pem".into(),
ca: "ca.pem".into(),
}),
}),
exec: Some(ExecSettings {
provider: Some("openai".into()),
model: Some("gpt-5.4".into()),
permissions: Some(PermissionLevel::ReadWrite),
output_format: Some(OutputFormat::Json),
}),
prevent_idle_sleep: Some(true),
verbose: Some(true),
upgrade_check: Some(false),
dry_run: Some(true),
auto_approve: Some(true),
no_retro: Some(true),
storage_dir: Some("/data".into()),
max_concurrent_runs: Some(10),
web: Some(WebSettings {
@ -380,6 +409,9 @@ fn fully_populated_server_config() -> Settings {
github: Some(GitHubSettings {
permissions: std::collections::HashMap::from([("contents".into(), "read".into())]),
}),
fabro: Some(ProjectSettings {
root: "fabro".into(),
}),
..Default::default()
}
}