From 18cb752b61ce8cfb901e9e0a0467d47c8ec94b90 Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Wed, 2 Sep 2026 17:15:52 -0400 Subject: [PATCH] Reject automatic pull requests for Local runs --- docs/public/execution/run-configuration.mdx | 2 + lib/apps/fabro-server/src/run_intent.rs | 4 + .../fabro-server/src/server/handler/runs.rs | 14 +++ lib/apps/fabro-server/src/server/tests.rs | 117 ++++++++++++++++++ 4 files changed, 137 insertions(+) diff --git a/docs/public/execution/run-configuration.mdx b/docs/public/execution/run-configuration.mdx index 7db919dba..e3eaa93cc 100644 --- a/docs/public/execution/run-configuration.mdx +++ b/docs/public/execution/run-configuration.mdx @@ -557,6 +557,8 @@ The `sandbox` transport runs the MCP server inside the workflow's sandbox. This Automatically open a GitHub pull request when the workflow run completes successfully. Requires a [GitHub App](/integrations/github) to be configured. +Automatic pull requests require a clone-based Docker or Daytona environment. For in-place Local runs, leave pull requests disabled; run creation rejects a Local environment when `enabled = true`. + ```toml title="run.toml" [run.pull_request] enabled = true diff --git a/lib/apps/fabro-server/src/run_intent.rs b/lib/apps/fabro-server/src/run_intent.rs index 489306828..68aa7c7a8 100644 --- a/lib/apps/fabro-server/src/run_intent.rs +++ b/lib/apps/fabro-server/src/run_intent.rs @@ -133,6 +133,10 @@ pub(crate) enum EnvironmentSelectionError { NotFound { id: EnvironmentId }, #[error("{detail}")] TargetUnsupported { detail: &'static str }, + #[error( + "automatic pull requests require a clone-based Docker or Daytona environment; disable run.pull_request.enabled for Local execution" + )] + AutomaticPullRequestUnsupported, #[error("{detail}")] ProviderDisabled { provider: SandboxProviderKind, diff --git a/lib/apps/fabro-server/src/server/handler/runs.rs b/lib/apps/fabro-server/src/server/handler/runs.rs index 236e0ec63..decd392fd 100644 --- a/lib/apps/fabro-server/src/server/handler/runs.rs +++ b/lib/apps/fabro-server/src/server/handler/runs.rs @@ -1041,6 +1041,11 @@ fn run_intent_admission_error(error: RunIntentAdmissionError) -> Response { error.to_string(), "target_environment_unsupported", ), + EnvironmentSelectionError::AutomaticPullRequestUnsupported => intent_error( + StatusCode::UNPROCESSABLE_ENTITY, + error.to_string(), + "pull_request_environment_unsupported", + ), EnvironmentSelectionError::ProviderDisabled { .. } | EnvironmentSelectionError::MissingCredential { .. } => intent_error( StatusCode::SERVICE_UNAVAILABLE, @@ -1118,6 +1123,15 @@ async fn validate_intent_environment( if image_incompatible || target_incompatible { return Err(EnvironmentSelectionError::TargetUnsupported { detail }); } + if configured_provider == SandboxProviderKind::Local + && settings + .run + .pull_request + .as_ref() + .is_some_and(|pull_request| pull_request.enabled) + { + return Err(EnvironmentSelectionError::AutomaticPullRequestUnsupported); + } if let Some(detail) = run_manifest::sandbox_provider_policy_error(&state.server_settings(), effective_provider) { diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index 7f23d1989..887f0c7da 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -4251,6 +4251,123 @@ async fn post_runs_run_intent_canonicalizes_and_persists_a_local_folder_target() assert!(projection.spec.definition_blob.is_some()); } +#[tokio::test] +async fn post_runs_run_intent_rejects_automatic_pull_requests_for_local_environment() { + let target = tempfile::tempdir().unwrap(); + let state = local_test_app_state(); + let app = crate::test_support::build_test_router(Arc::clone(&state)); + let workflow_version_id = store_workflow_version( + &state, + MINIMAL_DOT, + Some("_version = 1\n[run.pull_request]\nenabled = true\n"), + ) + .await; + + let response = + post_run_intent_response(&app, folder_intent(workflow_version_id, target.path())).await; + let body = response_json!(response, StatusCode::UNPROCESSABLE_ENTITY).await; + + assert_eq!( + body["errors"][0]["code"], + "pull_request_environment_unsupported" + ); + assert_eq!( + body["errors"][0]["detail"], + "automatic pull requests require a clone-based Docker or Daytona environment; disable run.pull_request.enabled for Local execution" + ); + assert!(state.runs.lock().expect("runs lock poisoned").is_empty()); + assert!( + state + .stores + .run_summaries + .list_identities() + .await + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn post_runs_run_intent_accepts_disabled_pull_requests_for_local_environment() { + let target = tempfile::tempdir().unwrap(); + let state = local_test_app_state(); + let app = crate::test_support::build_test_router(Arc::clone(&state)); + let workflow_version_id = store_workflow_version( + &state, + MINIMAL_DOT, + Some("_version = 1\n[run.pull_request]\nenabled = false\n"), + ) + .await; + + let body = post_run_manifest(&app, folder_intent(workflow_version_id, target.path())).await; + let run_id = body["id"].as_str().unwrap().parse::().unwrap(); + let projection = state + .stores + .runs + .open_run_reader(&run_id) + .await + .unwrap() + .state() + .await + .unwrap(); + + assert_eq!( + projection.spec.settings.run.environment.provider, + EnvironmentProvider::Local + ); + assert!(projection.spec.settings.run.pull_request.is_none()); +} + +#[tokio::test] +async fn post_runs_run_intent_accepts_automatic_pull_requests_for_configured_docker_dry_run() { + let state = test_app_state(); + let app = crate::test_support::build_test_router(Arc::clone(&state)); + let workflow_version_id = store_workflow_version( + &state, + MINIMAL_DOT, + Some("_version = 1\n[run.pull_request]\nenabled = true\n"), + ) + .await; + + let body = post_run_manifest( + &app, + json!({ + "workflow_version_id": workflow_version_id, + "target": { + "kind": "git", + "repo": "fabro-sh/fabro", + "branch": "main" + }, + "args": { "dry_run": true } + }), + ) + .await; + let run_id = body["id"].as_str().unwrap().parse::().unwrap(); + let projection = state + .stores + .runs + .open_run_reader(&run_id) + .await + .unwrap() + .state() + .await + .unwrap(); + + assert_eq!( + projection.spec.settings.run.environment.provider, + EnvironmentProvider::Docker + ); + assert_eq!(projection.spec.settings.run.execution.mode, RunMode::DryRun); + assert!( + projection + .spec + .settings + .run + .pull_request + .is_some_and(|pull_request| pull_request.enabled) + ); +} + #[tokio::test] async fn post_runs_run_intent_observes_folder_git_metadata_without_a_remote_call() { let dir = tempfile::tempdir().unwrap();