mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-07 03:00:29 +00:00
fix(mcp): reuse CLI auth and run overrides
This commit is contained in:
parent
81365a1cbf
commit
947001d719
5 changed files with 426 additions and 141 deletions
|
|
@ -2,14 +2,11 @@ use std::collections::HashMap;
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use fabro_config::{
|
||||
CliLayer, CliOutputLayer, ReplaceMap, RunExecutionLayer, RunGoalLayer, RunLayer, RunModelLayer,
|
||||
RunSandboxLayer, parse_input_overrides,
|
||||
};
|
||||
use fabro_config::{CliLayer, CliOutputLayer, RunGoalLayer, RunLayer, parse_input_overrides};
|
||||
use fabro_manifest::{RunOverrideInput, build_run_overrides};
|
||||
use fabro_sandbox::SandboxProvider;
|
||||
use fabro_types::settings::cli::OutputVerbosity;
|
||||
use fabro_types::settings::interp::InterpString;
|
||||
use fabro_types::settings::run::{ApprovalMode, RunMode};
|
||||
|
||||
use crate::args::{PreflightArgs, RunArgs};
|
||||
|
||||
|
|
@ -32,47 +29,6 @@ pub(crate) fn parse_labels(labels: &[String]) -> HashMap<String, String> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn model_from_args(model: Option<&str>, provider: Option<&str>) -> Option<RunModelLayer> {
|
||||
if model.is_none() && provider.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(RunModelLayer {
|
||||
provider: provider.map(InterpString::parse),
|
||||
name: model.map(InterpString::parse),
|
||||
fallbacks: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn sandbox_layer(
|
||||
sandbox: Option<SandboxProvider>,
|
||||
preserve: Option<bool>,
|
||||
) -> Option<RunSandboxLayer> {
|
||||
if sandbox.is_none() && preserve.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(RunSandboxLayer {
|
||||
provider: sandbox.map(|p| p.to_string()),
|
||||
preserve,
|
||||
..RunSandboxLayer::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn execution_layer(dry_run: Option<bool>, auto_approve: Option<bool>) -> Option<RunExecutionLayer> {
|
||||
if dry_run.is_none() && auto_approve.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(RunExecutionLayer {
|
||||
mode: dry_run.map(|d| if d { RunMode::DryRun } else { RunMode::Normal }),
|
||||
approval: auto_approve.map(|a| {
|
||||
if a {
|
||||
ApprovalMode::Auto
|
||||
} else {
|
||||
ApprovalMode::Prompt
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
fn cli_layer_for_verbose(verbose: bool) -> Option<CliLayer> {
|
||||
verbose.then(|| CliLayer {
|
||||
output: Some(CliOutputLayer {
|
||||
|
|
@ -119,24 +75,21 @@ fn current_dir_or_dot() -> PathBuf {
|
|||
}
|
||||
|
||||
pub(crate) fn run_args_overrides(args: &RunArgs) -> Result<ManifestSettingsOverrides> {
|
||||
let model = model_from_args(args.model.as_deref(), args.provider.as_deref());
|
||||
let sandbox = sandbox_layer(
|
||||
args.sandbox.map(Into::into),
|
||||
sparse_flag(args.preserve_sandbox),
|
||||
);
|
||||
let execution = execution_layer(sparse_flag(args.dry_run), sparse_flag(args.auto_approve));
|
||||
|
||||
let cwd = current_dir_or_dot();
|
||||
let goal = goal_layer_from_args(args.goal.as_deref(), args.goal_file.as_deref(), &cwd)?;
|
||||
|
||||
let run = RunLayer {
|
||||
goal,
|
||||
metadata: ReplaceMap::from(parse_labels(&args.label)),
|
||||
model,
|
||||
sandbox,
|
||||
execution,
|
||||
..RunLayer::default()
|
||||
};
|
||||
let sandbox = args.sandbox.map(SandboxProvider::from);
|
||||
let sandbox_provider = sandbox.as_ref().map(ToString::to_string);
|
||||
let mut run = build_run_overrides(RunOverrideInput {
|
||||
goal: None,
|
||||
model: args.model.as_deref(),
|
||||
provider: args.provider.as_deref(),
|
||||
sandbox: sandbox_provider.as_deref(),
|
||||
preserve_sandbox: sparse_flag(args.preserve_sandbox),
|
||||
dry_run: sparse_flag(args.dry_run),
|
||||
auto_approve: sparse_flag(args.auto_approve),
|
||||
labels: parse_labels(&args.label),
|
||||
});
|
||||
run.goal = goal;
|
||||
|
||||
Ok(ManifestSettingsOverrides {
|
||||
run: Some(run),
|
||||
|
|
@ -146,21 +99,22 @@ pub(crate) fn run_args_overrides(args: &RunArgs) -> Result<ManifestSettingsOverr
|
|||
}
|
||||
|
||||
pub(crate) fn preflight_args_overrides(args: &PreflightArgs) -> Result<ManifestSettingsOverrides> {
|
||||
let model = model_from_args(args.model.as_deref(), args.provider.as_deref());
|
||||
let sandbox = args.sandbox.map(|s| RunSandboxLayer {
|
||||
provider: Some(SandboxProvider::from(s).to_string()),
|
||||
..RunSandboxLayer::default()
|
||||
});
|
||||
|
||||
let cwd = current_dir_or_dot();
|
||||
let goal = goal_layer_from_args(args.goal.as_deref(), args.goal_file.as_deref(), &cwd)?;
|
||||
|
||||
let run = RunLayer {
|
||||
goal,
|
||||
model,
|
||||
sandbox,
|
||||
..RunLayer::default()
|
||||
};
|
||||
let sandbox_provider = args
|
||||
.sandbox
|
||||
.map(|sandbox| SandboxProvider::from(sandbox).to_string());
|
||||
let mut run = build_run_overrides(RunOverrideInput {
|
||||
goal: None,
|
||||
model: args.model.as_deref(),
|
||||
provider: args.provider.as_deref(),
|
||||
sandbox: sandbox_provider.as_deref(),
|
||||
preserve_sandbox: None,
|
||||
dry_run: None,
|
||||
auto_approve: None,
|
||||
labels: HashMap::new(),
|
||||
});
|
||||
run.goal = goal;
|
||||
|
||||
Ok(ManifestSettingsOverrides {
|
||||
run: Some(run),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ use std::io::{BufRead as _, Write as _};
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use fabro_client::{AuthEntry, AuthStore, OAuthEntry, StoredSubject};
|
||||
use fabro_mcp::client::McpClient;
|
||||
use fabro_mcp::config::{McpServerSettings, McpTransport};
|
||||
use fabro_test::{fabro_json_snapshot, fabro_snapshot, test_context};
|
||||
|
|
@ -659,6 +661,100 @@ async fn mcp_search_includes_archived_runs_by_default() {
|
|||
.expect("MCP client should shut down");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn mcp_search_refreshes_expired_oauth_token() {
|
||||
let context = test_context!();
|
||||
let server = MockServer::start();
|
||||
let target_url = format!("{}/api/v1", server.base_url());
|
||||
let target: fabro_client::ServerTarget = target_url.parse().unwrap();
|
||||
seed_oauth_auth(
|
||||
&context.home_dir,
|
||||
&target,
|
||||
"expired-access",
|
||||
"refresh-octocat",
|
||||
);
|
||||
let run_id = unique_run_id();
|
||||
let expired_access = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api/v1/runs")
|
||||
.header("authorization", "Bearer expired-access");
|
||||
then.status(401)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(serde_json::json!({
|
||||
"errors": [{
|
||||
"detail": "access token expired",
|
||||
"code": "access_token_expired"
|
||||
}]
|
||||
}));
|
||||
});
|
||||
let refresh = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/auth/cli/refresh")
|
||||
.header("authorization", "Bearer refresh-octocat");
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(serde_json::json!({
|
||||
"access_token": "fresh-access",
|
||||
"access_token_expires_at": (Utc::now() + ChronoDuration::minutes(10)).to_rfc3339(),
|
||||
"refresh_token": "fresh-refresh",
|
||||
"refresh_token_expires_at": (Utc::now() + ChronoDuration::days(30)).to_rfc3339(),
|
||||
"subject": {
|
||||
"idp_issuer": "https://github.com",
|
||||
"idp_subject": "12345",
|
||||
"login": "octocat",
|
||||
"name": "The Octocat",
|
||||
"email": "octocat@example.com"
|
||||
}
|
||||
}));
|
||||
});
|
||||
let fresh_access = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api/v1/runs")
|
||||
.header("authorization", "Bearer fresh-access")
|
||||
.query_param("include_archived", "true")
|
||||
.query_param("page[limit]", "100")
|
||||
.query_param("page[offset]", "0");
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(serde_json::json!({
|
||||
"data": [remote_run_summary_json(
|
||||
&run_id,
|
||||
"Simple",
|
||||
"simple",
|
||||
"OAuth refreshed",
|
||||
&serde_json::json!({ "kind": "submitted" }),
|
||||
"2026-04-05T12:00:00Z",
|
||||
)],
|
||||
"meta": { "has_more": false }
|
||||
}));
|
||||
});
|
||||
let client = spawn_mcp_client(&context, &["--server", &target_url]).await;
|
||||
|
||||
let result = call_tool_json(
|
||||
&client,
|
||||
"fabro_run_search",
|
||||
serde_json::json!({ "run_ids": [run_id], "first": 1 }),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(result["runs"][0]["run_id"], run_id);
|
||||
expired_access.assert();
|
||||
refresh.assert();
|
||||
fresh_access.assert();
|
||||
let stored = AuthStore::new(context.home_dir.join(".fabro/auth.json"))
|
||||
.get(&target)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let AuthEntry::OAuth(stored) = stored else {
|
||||
panic!("expected refreshed OAuth entry");
|
||||
};
|
||||
assert_eq!(stored.access_token, "fresh-access");
|
||||
client
|
||||
.shutdown()
|
||||
.await
|
||||
.expect("MCP client should shut down");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn mcp_search_orders_by_started_timestamp_before_created_timestamp() {
|
||||
let context = test_context!();
|
||||
|
|
@ -1496,6 +1592,7 @@ async fn mcp_events_desc_after_offset_and_limit_page_over_requested_order() {
|
|||
.await;
|
||||
|
||||
assert_eq!(desc["events"][0]["event_id"], "evt-5");
|
||||
assert_eq!(desc["next_cursor"], 5);
|
||||
assert_eq!(paged["events"][0]["event_id"], "evt-3");
|
||||
assert_eq!(paged["events"][1]["event_id"], "evt-4");
|
||||
assert_eq!(paged["next_cursor"], 5);
|
||||
|
|
@ -1509,6 +1606,80 @@ async fn mcp_events_desc_after_offset_and_limit_page_over_requested_order() {
|
|||
.expect("MCP client should shut down");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn mcp_events_desc_cursor_continues_to_older_events() {
|
||||
let context = test_context!();
|
||||
let server = MockServer::start();
|
||||
let target_url = format!("{}/api/v1", server.base_url());
|
||||
let target: fabro_client::ServerTarget = target_url.parse().unwrap();
|
||||
seed_dev_token_auth(&context.home_dir, &target, TEST_DEV_TOKEN);
|
||||
let run_id = unique_run_id();
|
||||
let resolve = mock_resolved_run(&server, "nightly", &run_id);
|
||||
let events = (1..=5)
|
||||
.map(|sequence| {
|
||||
serde_json::json!({
|
||||
"seq": sequence,
|
||||
"id": format!("evt-{sequence}"),
|
||||
"ts": format!("2026-04-05T12:00:0{sequence}Z"),
|
||||
"run_id": run_id,
|
||||
"event": "run.started",
|
||||
"properties": { "name": format!("event {sequence}") },
|
||||
"actor": null
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let full_events = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path(format!("/api/v1/runs/{run_id}/events"))
|
||||
.query_param_missing("limit")
|
||||
.query_param_missing("since_seq");
|
||||
then.status(200)
|
||||
.header("Content-Type", "application/json")
|
||||
.json_body(serde_json::json!({
|
||||
"data": events,
|
||||
"meta": { "has_more": false }
|
||||
}));
|
||||
});
|
||||
let client = spawn_mcp_client(&context, &["--server", &target_url]).await;
|
||||
|
||||
let first_page = call_tool_json(
|
||||
&client,
|
||||
"fabro_run_events",
|
||||
serde_json::json!({
|
||||
"run_id": "nightly",
|
||||
"action": "list",
|
||||
"direction": "desc",
|
||||
"first": 2
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let second_page = call_tool_json(
|
||||
&client,
|
||||
"fabro_run_events",
|
||||
serde_json::json!({
|
||||
"run_id": "nightly",
|
||||
"action": "list",
|
||||
"direction": "desc",
|
||||
"after": first_page["next_cursor"],
|
||||
"first": 2
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(first_page["events"][0]["event_id"], "evt-5");
|
||||
assert_eq!(first_page["events"][1]["event_id"], "evt-4");
|
||||
assert_eq!(first_page["next_cursor"], 4);
|
||||
assert_eq!(second_page["events"][0]["event_id"], "evt-3");
|
||||
assert_eq!(second_page["events"][1]["event_id"], "evt-2");
|
||||
assert_eq!(second_page["next_cursor"], 2);
|
||||
resolve.assert_calls(2);
|
||||
full_events.assert_calls(2);
|
||||
client
|
||||
.shutdown()
|
||||
.await
|
||||
.expect("MCP client should shut down");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn mcp_events_offset_beyond_fetch_cap_reaches_later_pages() {
|
||||
let context = test_context!();
|
||||
|
|
@ -1745,6 +1916,34 @@ async fn create_mcp_run(client: &McpClient, workflow: PathBuf, start: bool) -> S
|
|||
.to_string()
|
||||
}
|
||||
|
||||
fn seed_oauth_auth(
|
||||
home_dir: &Path,
|
||||
target: &fabro_client::ServerTarget,
|
||||
access_token: &str,
|
||||
refresh_token: &str,
|
||||
) {
|
||||
let now = Utc::now();
|
||||
AuthStore::new(home_dir.join(".fabro/auth.json"))
|
||||
.put(
|
||||
target,
|
||||
AuthEntry::OAuth(OAuthEntry {
|
||||
access_token: access_token.to_string(),
|
||||
access_token_expires_at: now - ChronoDuration::minutes(1),
|
||||
refresh_token: refresh_token.to_string(),
|
||||
refresh_token_expires_at: now + ChronoDuration::days(30),
|
||||
subject: StoredSubject {
|
||||
idp_issuer: "https://github.com".to_string(),
|
||||
idp_subject: "12345".to_string(),
|
||||
login: "octocat".to_string(),
|
||||
name: "The Octocat".to_string(),
|
||||
email: "octocat@example.com".to_string(),
|
||||
},
|
||||
logged_in_at: now,
|
||||
}),
|
||||
)
|
||||
.unwrap_or_else(|err| panic!("failed to seed OAuth auth: {err}"));
|
||||
}
|
||||
|
||||
fn normalize_run_search(mut value: serde_json::Value) -> serde_json::Value {
|
||||
if let Some(runs) = value["runs"].as_array_mut() {
|
||||
for run in runs {
|
||||
|
|
|
|||
|
|
@ -10,11 +10,15 @@ use anyhow::{Context, Result, anyhow};
|
|||
use fabro_api::types;
|
||||
use fabro_config::project::{self, discover_project_config, resolve_workflow_path};
|
||||
use fabro_config::run::{resolve_run_goal_from_layer, resolve_run_goal_from_namespace};
|
||||
use fabro_config::{CliLayer, DaytonaDockerfileLayer, RunLayer, WorkflowSettingsBuilder};
|
||||
use fabro_config::{
|
||||
CliLayer, DaytonaDockerfileLayer, ReplaceMap, RunExecutionLayer, RunGoalLayer, RunLayer,
|
||||
RunModelLayer, RunSandboxLayer, WorkflowSettingsBuilder,
|
||||
};
|
||||
use fabro_graphviz::graph::AttrValue;
|
||||
use fabro_graphviz::parser;
|
||||
use fabro_template::{TemplateContext, render as render_template};
|
||||
use fabro_types::settings::run::{ResolvedGoalSource, ResolvedRunGoal};
|
||||
use fabro_types::settings::interp::InterpString;
|
||||
use fabro_types::settings::run::{ApprovalMode, ResolvedGoalSource, ResolvedRunGoal, RunMode};
|
||||
use fabro_types::{DirtyStatus, GitContext, PreRunPushOutcome, RunId, WorkflowSettings};
|
||||
use fabro_workflow::ManifestPath;
|
||||
use fabro_workflow::git::{
|
||||
|
|
@ -41,6 +45,73 @@ pub struct BuiltManifest {
|
|||
pub target_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RunOverrideInput<'a> {
|
||||
pub goal: Option<&'a str>,
|
||||
pub model: Option<&'a str>,
|
||||
pub provider: Option<&'a str>,
|
||||
pub sandbox: Option<&'a str>,
|
||||
pub preserve_sandbox: Option<bool>,
|
||||
pub dry_run: Option<bool>,
|
||||
pub auto_approve: Option<bool>,
|
||||
pub labels: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn build_run_overrides(input: RunOverrideInput<'_>) -> RunLayer {
|
||||
let goal = input
|
||||
.goal
|
||||
.map(|goal| RunGoalLayer::Inline(InterpString::parse(goal)));
|
||||
let model = (input.model.is_some() || input.provider.is_some()).then(|| RunModelLayer {
|
||||
provider: input.provider.map(InterpString::parse),
|
||||
name: input.model.map(InterpString::parse),
|
||||
fallbacks: Vec::new(),
|
||||
});
|
||||
let sandbox =
|
||||
(input.sandbox.is_some() || input.preserve_sandbox.is_some()).then(|| RunSandboxLayer {
|
||||
provider: input.sandbox.map(ToOwned::to_owned),
|
||||
preserve: input.preserve_sandbox,
|
||||
..RunSandboxLayer::default()
|
||||
});
|
||||
let execution =
|
||||
(input.dry_run.is_some() || input.auto_approve.is_some()).then(|| RunExecutionLayer {
|
||||
mode: input.dry_run.map(|dry_run| {
|
||||
if dry_run {
|
||||
RunMode::DryRun
|
||||
} else {
|
||||
RunMode::Normal
|
||||
}
|
||||
}),
|
||||
approval: input.auto_approve.map(|auto_approve| {
|
||||
if auto_approve {
|
||||
ApprovalMode::Auto
|
||||
} else {
|
||||
ApprovalMode::Prompt
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
RunLayer {
|
||||
goal,
|
||||
metadata: ReplaceMap::from(input.labels),
|
||||
model,
|
||||
sandbox,
|
||||
execution,
|
||||
..RunLayer::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn build_sparse_run_overrides(input: RunOverrideInput<'_>) -> Option<RunLayer> {
|
||||
let run = build_run_overrides(input);
|
||||
(run.goal.is_some()
|
||||
|| !run.metadata.is_empty()
|
||||
|| run.model.is_some()
|
||||
|| run.sandbox.is_some()
|
||||
|| run.execution.is_some())
|
||||
.then_some(run)
|
||||
}
|
||||
|
||||
struct CollectContext<'a> {
|
||||
cwd: &'a Path,
|
||||
inputs: &'a HashMap<String, toml::Value>,
|
||||
|
|
@ -629,6 +700,64 @@ pub fn manifest_args_is_empty(args: &types::ManifestArgs) -> bool {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_run_overrides_sets_common_cli_and_mcp_layers() {
|
||||
let overrides = build_run_overrides(RunOverrideInput {
|
||||
goal: Some("ship it"),
|
||||
model: Some("gpt-5.4-mini"),
|
||||
provider: Some("openai"),
|
||||
sandbox: Some("local"),
|
||||
preserve_sandbox: Some(true),
|
||||
dry_run: Some(true),
|
||||
auto_approve: Some(false),
|
||||
labels: [("source".to_string(), "mcp".to_string())]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
});
|
||||
|
||||
let goal = overrides.goal.expect("goal override");
|
||||
assert!(matches!(goal, fabro_config::RunGoalLayer::Inline(_)));
|
||||
assert_eq!(
|
||||
overrides
|
||||
.model
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.name
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.as_source(),
|
||||
"gpt-5.4-mini"
|
||||
);
|
||||
assert_eq!(
|
||||
overrides
|
||||
.model
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.provider
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.as_source(),
|
||||
"openai"
|
||||
);
|
||||
assert_eq!(
|
||||
overrides.sandbox.as_ref().unwrap().provider.as_deref(),
|
||||
Some("local")
|
||||
);
|
||||
assert_eq!(overrides.sandbox.as_ref().unwrap().preserve, Some(true));
|
||||
assert_eq!(
|
||||
overrides.execution.as_ref().unwrap().mode,
|
||||
Some(RunMode::DryRun)
|
||||
);
|
||||
assert_eq!(
|
||||
overrides.execution.as_ref().unwrap().approval,
|
||||
Some(ApprovalMode::Prompt)
|
||||
);
|
||||
assert_eq!(
|
||||
overrides.metadata.0.get("source").map(String::as_str),
|
||||
Some("mcp")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_manifest_bundles_imports_prompts_and_children() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -11,13 +11,12 @@ use std::time::{Duration, Instant};
|
|||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use fabro_api::types;
|
||||
use fabro_client::Client;
|
||||
use fabro_config::{
|
||||
CliLayer, ReplaceMap, RunExecutionLayer, RunGoalLayer, RunLayer, RunModelLayer, RunSandboxLayer,
|
||||
use fabro_config::{CliLayer, RunLayer};
|
||||
use fabro_manifest::{
|
||||
ManifestBuildInput, RunOverrideInput, build_run_manifest as build_canonical_run_manifest,
|
||||
build_sparse_run_overrides,
|
||||
};
|
||||
use fabro_manifest::{ManifestBuildInput, build_run_manifest as build_canonical_run_manifest};
|
||||
use fabro_server::manifest_validation;
|
||||
use fabro_types::settings::InterpString;
|
||||
use fabro_types::settings::run::{ApprovalMode, RunMode};
|
||||
use fabro_types::{EventEnvelope, Run, RunId, RunStatus};
|
||||
use fabro_util::exit::{self, ExitClass};
|
||||
use rmcp::model::{CallToolResult, Content};
|
||||
|
|
@ -596,12 +595,19 @@ pub(crate) async fn run_events(
|
|||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?
|
||||
.id;
|
||||
let descending = raw.direction.as_deref() == Some("desc");
|
||||
let fetch_after = if descending { None } else { raw.after };
|
||||
let mut events = client
|
||||
.list_run_events(&run_id, raw.after, event_fetch_limit(&raw))
|
||||
.list_run_events(&run_id, fetch_after, event_fetch_limit(&raw))
|
||||
.await
|
||||
.map_err(|err| ToolError::from_anyhow(&err))?;
|
||||
if descending {
|
||||
if let Some(after) = raw.after {
|
||||
events.retain(|event| event.seq < after);
|
||||
}
|
||||
}
|
||||
filter_events(&mut events, &raw)?;
|
||||
if raw.direction.as_deref() == Some("desc") {
|
||||
if descending {
|
||||
events.reverse();
|
||||
}
|
||||
let offset = raw.offset.unwrap_or(0);
|
||||
|
|
@ -616,7 +622,13 @@ pub(crate) async fn run_events(
|
|||
.iter()
|
||||
.map(|event| run_event_result(event, max_content_length))
|
||||
.collect::<ToolResult<Vec<_>>>()?;
|
||||
let next_cursor = page.last().map(|event| event.seq.saturating_add(1));
|
||||
let next_cursor = page.last().map(|event| {
|
||||
if descending {
|
||||
event.seq
|
||||
} else {
|
||||
event.seq.saturating_add(1)
|
||||
}
|
||||
});
|
||||
|
||||
Ok(RunEventsResult {
|
||||
run_id: run_id.to_string(),
|
||||
|
|
@ -902,52 +914,16 @@ fn mcp_manifest_args(spec: &CreateRunSpec) -> Option<types::ManifestArgs> {
|
|||
}
|
||||
|
||||
fn mcp_run_overrides(spec: &CreateRunSpec) -> Option<RunLayer> {
|
||||
let goal = spec
|
||||
.goal
|
||||
.as_ref()
|
||||
.map(|goal| RunGoalLayer::Inline(InterpString::parse(goal)));
|
||||
let model = (spec.model.is_some() || spec.provider.is_some()).then(|| RunModelLayer {
|
||||
provider: spec.provider.as_deref().map(InterpString::parse),
|
||||
name: spec.model.as_deref().map(InterpString::parse),
|
||||
fallbacks: Vec::new(),
|
||||
});
|
||||
let sandbox =
|
||||
(spec.sandbox.is_some() || spec.preserve_sandbox.is_some()).then(|| RunSandboxLayer {
|
||||
provider: spec.sandbox.clone(),
|
||||
preserve: spec.preserve_sandbox,
|
||||
..RunSandboxLayer::default()
|
||||
});
|
||||
let execution =
|
||||
(spec.dry_run.is_some() || spec.auto_approve.is_some()).then(|| RunExecutionLayer {
|
||||
mode: spec.dry_run.map(|dry_run| {
|
||||
if dry_run {
|
||||
RunMode::DryRun
|
||||
} else {
|
||||
RunMode::Normal
|
||||
}
|
||||
}),
|
||||
approval: spec.auto_approve.map(|auto_approve| {
|
||||
if auto_approve {
|
||||
ApprovalMode::Auto
|
||||
} else {
|
||||
ApprovalMode::Prompt
|
||||
}
|
||||
}),
|
||||
});
|
||||
let run = RunLayer {
|
||||
goal,
|
||||
metadata: ReplaceMap::from(spec.labels.clone()),
|
||||
model,
|
||||
sandbox,
|
||||
execution,
|
||||
..RunLayer::default()
|
||||
};
|
||||
(run.goal.is_some()
|
||||
|| !run.metadata.is_empty()
|
||||
|| run.model.is_some()
|
||||
|| run.sandbox.is_some()
|
||||
|| run.execution.is_some())
|
||||
.then_some(run)
|
||||
build_sparse_run_overrides(RunOverrideInput {
|
||||
goal: spec.goal.as_deref(),
|
||||
model: spec.model.as_deref(),
|
||||
provider: spec.provider.as_deref(),
|
||||
sandbox: spec.sandbox.as_deref(),
|
||||
preserve_sandbox: spec.preserve_sandbox,
|
||||
dry_run: spec.dry_run,
|
||||
auto_approve: spec.auto_approve,
|
||||
labels: spec.labels.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn mcp_manifest_args_is_empty(args: &types::ManifestArgs) -> bool {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::time::Duration;
|
|||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use fabro_client::{
|
||||
AuthEntry, AuthStore, Client, Credential, ServerTarget, TransportConnector,
|
||||
AuthEntry, AuthStore, Client, Credential, OAuthSession, ServerTarget, TransportConnector,
|
||||
apply_bearer_token_auth,
|
||||
};
|
||||
use fabro_config::bind::Bind;
|
||||
|
|
@ -194,15 +194,15 @@ async fn client_from_settings(settings: &McpServerSettings) -> Result<Client> {
|
|||
|
||||
async fn connect_target(server: &str, settings: &McpServerSettings) -> Result<Client> {
|
||||
let target: ServerTarget = server.parse()?;
|
||||
let mut credential = AuthStore::new(settings.home_dir.join(".fabro").join("auth.json"))
|
||||
.get(&target)?
|
||||
.map(credential_from_auth_entry);
|
||||
let auth_store = AuthStore::new(settings.home_dir.join(".fabro").join("auth.json"));
|
||||
let mut credential = resolve_target_credential_with_store(&target, &auth_store)?;
|
||||
if credential.is_none() && target.is_unix_socket() {
|
||||
let runtime_token_path = Storage::new(&settings.storage_dir)
|
||||
.runtime_directory()
|
||||
.dev_token_path();
|
||||
credential = dev_token::read_dev_token_file(&runtime_token_path).map(Credential::DevToken);
|
||||
}
|
||||
let oauth_session = refreshable_oauth(&target, &auth_store, credential.as_ref());
|
||||
let mut builder = Client::builder()
|
||||
.target(target.clone())
|
||||
.transport_connector(target_transport_connector(target))
|
||||
|
|
@ -210,6 +210,9 @@ async fn connect_target(server: &str, settings: &McpServerSettings) -> Result<Cl
|
|||
if let Some(credential) = credential {
|
||||
builder = builder.credential(credential);
|
||||
}
|
||||
if let Some(oauth_session) = oauth_session {
|
||||
builder = builder.oauth_session(oauth_session);
|
||||
}
|
||||
builder
|
||||
.connect()
|
||||
.await
|
||||
|
|
@ -235,9 +238,9 @@ async fn connect_local_server(settings: &McpServerSettings) -> Result<Client> {
|
|||
}
|
||||
Bind::Tcp(addr) => {
|
||||
let target = ServerTarget::http_url(format!("http://{addr}"))?;
|
||||
let credential = AuthStore::new(settings.home_dir.join(".fabro").join("auth.json"))
|
||||
.get(&target)?
|
||||
.map(credential_from_auth_entry);
|
||||
let auth_store = AuthStore::new(settings.home_dir.join(".fabro").join("auth.json"));
|
||||
let credential = resolve_target_credential_with_store(&target, &auth_store)?;
|
||||
let oauth_session = refreshable_oauth(&target, &auth_store, credential.as_ref());
|
||||
let mut builder = Client::builder()
|
||||
.target(target.clone())
|
||||
.transport_connector(target_transport_connector(target))
|
||||
|
|
@ -245,6 +248,9 @@ async fn connect_local_server(settings: &McpServerSettings) -> Result<Client> {
|
|||
if let Some(credential) = credential {
|
||||
builder = builder.credential(credential);
|
||||
}
|
||||
if let Some(oauth_session) = oauth_session {
|
||||
builder = builder.oauth_session(oauth_session);
|
||||
}
|
||||
builder.connect().await
|
||||
}
|
||||
}
|
||||
|
|
@ -299,13 +305,34 @@ async fn wait_for_runtime_dev_token(path: &Path) -> Result<String> {
|
|||
))
|
||||
}
|
||||
|
||||
fn credential_from_auth_entry(entry: AuthEntry) -> Credential {
|
||||
fn resolve_target_credential_with_store(
|
||||
target: &ServerTarget,
|
||||
store: &AuthStore,
|
||||
) -> Result<Option<Credential>> {
|
||||
let Some(entry) = store.get(target)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let now = chrono::Utc::now();
|
||||
match entry {
|
||||
AuthEntry::OAuth(entry) => Credential::OAuth(entry),
|
||||
AuthEntry::DevToken(entry) => Credential::DevToken(entry.token),
|
||||
AuthEntry::DevToken(entry) => Ok(Some(Credential::DevToken(entry.token))),
|
||||
AuthEntry::OAuth(entry)
|
||||
if entry.access_token_expires_at > now || entry.refresh_token_expires_at > now =>
|
||||
{
|
||||
Ok(Some(Credential::OAuth(entry)))
|
||||
}
|
||||
AuthEntry::OAuth(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn refreshable_oauth(
|
||||
target: &ServerTarget,
|
||||
auth_store: &AuthStore,
|
||||
credential: Option<&Credential>,
|
||||
) -> Option<OAuthSession> {
|
||||
matches!(credential, Some(Credential::OAuth(_)))
|
||||
.then(|| OAuthSession::new(target.clone(), auth_store.clone()))
|
||||
}
|
||||
|
||||
fn target_transport_connector(target: ServerTarget) -> TransportConnector {
|
||||
TransportConnector::new(move |bearer_token| {
|
||||
let target = target.clone();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue