From b0924a1746654db6025fffd9e2ad2dbd435c656f Mon Sep 17 00:00:00 2001 From: ITSGJY <133723203+ITSGJY@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:27:37 +0800 Subject: [PATCH] feat(project): add locked dependency policy --- crates/webcodex-core/src/project_build.rs | 18 +- .../webcodex-core/src/project_build_tests.rs | 46 ++++ crates/webcodex-core/src/project_operation.rs | 17 ++ .../src/project_operation_tests.rs | 2 + .../webcodex-core/src/project_validation.rs | 9 +- .../src/project_validation_tests.rs | 21 ++ crates/webcodex-core/src/runner_operation.rs | 1 + crates/webcodex-core/src/runner_protocol.rs | 35 ++- .../webcodex-core/src/runner_protocol/job.rs | 33 ++- .../webcodex-core/src/validation_identity.rs | 17 ++ crates/webcodex-runner-config/src/lib.rs | 1 + .../src/job_updates.rs | 18 ++ .../webcodex-runner-registry/src/requests.rs | 22 ++ crates/webcodex-runner-registry/src/tests.rs | 2 + .../src/tests/project_build.rs | 2 + .../src/tests/project_dependency_policy.rs | 239 ++++++++++++++++++ .../src/tests/reconciliation.rs | 3 +- crates/webcodex-runner/src/main.rs | 1 + .../job_manager_tests/project_build.rs | 2 + .../job_manager_tests/project_validation.rs | 2 + .../webcodex_runner/project_build_tests.rs | 35 +++ .../src/webcodex_runner/validation/project.rs | 6 +- .../validation/project_tests.rs | 37 +++ .../src/tests/input_schemas.rs | 33 +++ .../webcodex-tool-contracts/src/tool_call.rs | 8 + .../src/tool_catalog.rs | 4 +- .../src/tool_definition/jobs.rs | 2 +- .../src/tool_definition/testing.rs | 2 +- crates/webcodex-validation/src/adapters/go.rs | 10 + .../webcodex-validation/src/adapters/mod.rs | 66 ++++- .../webcodex-validation/src/adapters/rust.rs | 10 + crates/webcodex-validation/src/lib.rs | 3 +- .../webcodex-validation/src/profile_tests.rs | 79 ++++++ crates/webcodex-validation/src/recipe.rs | 32 ++- docs/MCP.md | 4 +- docs/MCP.zh-CN.md | 4 +- src/runner_quic.rs | 1 + src/runner_ws.rs | 1 + src/tool_runtime/cargo.rs | 2 + src/tool_runtime/cargo_tools.rs | 2 + src/tool_runtime/dispatch/execution.rs | 2 + src/tool_runtime/project_build.rs | 2 + src/tool_runtime/tests/metadata.rs | 1 + .../tests/project_build_handoff.rs | 1 + .../validation_handoff/project_validation.rs | 1 + src/tool_runtime/validation.rs | 2 + 46 files changed, 813 insertions(+), 28 deletions(-) create mode 100644 crates/webcodex-runner-registry/src/tests/project_dependency_policy.rs diff --git a/crates/webcodex-core/src/project_build.rs b/crates/webcodex-core/src/project_build.rs index 104407869..c4d3fd07e 100644 --- a/crates/webcodex-core/src/project_build.rs +++ b/crates/webcodex-core/src/project_build.rs @@ -5,7 +5,9 @@ use crate::runner_protocol::{normalize_cargo_packages, normalize_go_packages, Sh use serde::{Deserialize, Serialize}; /// Portable project-operation scope retained under the project_build API name. -pub use crate::project_operation::ProjectOperationScope as ProjectBuildScope; +pub use crate::project_operation::{ + ProjectDependencyMode, ProjectDependencyPolicy, ProjectOperationScope as ProjectBuildScope, +}; use sha2::{Digest, Sha256}; pub const PROJECT_BUILD_PROVENANCE_MAX_BYTES: usize = 8 * 1024; @@ -30,6 +32,8 @@ pub struct ProjectBuildRequest { pub adapter: ProjectBuildAdapter, #[serde(default, skip_serializing_if = "Option::is_none")] pub scope: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dependency_policy: Option, } impl ProjectBuildRequest { @@ -137,6 +141,12 @@ pub fn canonical_project_build_process( "rust" => { let packages = normalize_cargo_packages(None, packages)?; let mut args = vec!["build".to_string()]; + if request + .dependency_policy + .is_some_and(|policy| policy.mode == ProjectDependencyMode::Locked) + { + args.push("--locked".to_string()); + } if let Some(packages) = packages { for package in packages { args.push("-p".to_string()); @@ -151,6 +161,12 @@ pub fn canonical_project_build_process( "go" => { let packages = normalize_go_packages(packages)?; let mut args = vec!["build".to_string()]; + if request + .dependency_policy + .is_some_and(|policy| policy.mode == ProjectDependencyMode::Locked) + { + args.push("-mod=readonly".to_string()); + } args.extend(packages); Ok(ShellProcessArgv { executable: "go".to_string(), diff --git a/crates/webcodex-core/src/project_build_tests.rs b/crates/webcodex-core/src/project_build_tests.rs index 0ca043301..6b49116ad 100644 --- a/crates/webcodex-core/src/project_build_tests.rs +++ b/crates/webcodex-core/src/project_build_tests.rs @@ -10,6 +10,7 @@ fn request() -> ProjectBuildRequest { cwd: Some("src".into()), adapter: ProjectBuildAdapter::Auto, scope: None, + dependency_policy: None, } } @@ -94,6 +95,7 @@ fn start_build_wire_roundtrip_preserves_typed_process_and_provenance() { cwd: None, adapter: ProjectBuildAdapter::Rust, scope: None, + dependency_policy: None, }; let process = canonical_project_build_process("rust", &request).unwrap(); let provenance = ProjectBuildProvenance { @@ -185,3 +187,47 @@ fn start_build_wire_roundtrip_preserves_typed_process_and_provenance() { .unwrap_err() .contains("recovery metadata does not match typed operation")); } + +#[test] +fn project_build_locked_dependency_policy_maps_to_native_readonly_flags() { + let locked = ProjectDependencyPolicy { + mode: ProjectDependencyMode::Locked, + }; + for (backend, expected) in [ + ("rust", vec!["build", "--locked"]), + ("go", vec!["build", "-mod=readonly", "./..."]), + ] { + let request = ProjectBuildRequest { + project_id: "demo".into(), + cwd: None, + adapter: ProjectBuildAdapter::Auto, + scope: None, + dependency_policy: Some(locked), + }; + let process = canonical_project_build_process(backend, &request).unwrap(); + assert_eq!(process.args, expected); + } +} + +#[test] +fn project_build_default_dependency_policy_preserves_historical_argv() { + let request = ProjectBuildRequest { + project_id: "demo".into(), + cwd: None, + adapter: ProjectBuildAdapter::Auto, + scope: None, + dependency_policy: None, + }; + assert_eq!( + canonical_project_build_process("rust", &request) + .unwrap() + .args, + vec!["build"] + ); + assert_eq!( + canonical_project_build_process("go", &request) + .unwrap() + .args, + vec!["build", "./..."] + ); +} diff --git a/crates/webcodex-core/src/project_operation.rs b/crates/webcodex-core/src/project_operation.rs index d120e5132..c589790cf 100644 --- a/crates/webcodex-core/src/project_operation.rs +++ b/crates/webcodex-core/src/project_operation.rs @@ -5,6 +5,23 @@ use serde::{Deserialize, Serialize}; pub(crate) const PROJECT_OPERATION_SCOPE_MAX_PACKAGES: usize = 8; pub(crate) const PROJECT_OPERATION_SCOPE_MAX_PACKAGE_BYTES: usize = 256; +/// Portable dependency-resolution policy for bounded project operations. +/// +/// `Locked` means the adapter must not repair dependency selection by updating +/// the ecosystem's project-level resolution state. Network access is a separate +/// policy dimension and is intentionally not controlled here. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ProjectDependencyMode { + Locked, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ProjectDependencyPolicy { + pub mode: ProjectDependencyMode, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub struct ProjectOperationScope { diff --git a/crates/webcodex-core/src/project_operation_tests.rs b/crates/webcodex-core/src/project_operation_tests.rs index 4eb2a7322..8cd594d93 100644 --- a/crates/webcodex-core/src/project_operation_tests.rs +++ b/crates/webcodex-core/src/project_operation_tests.rs @@ -11,6 +11,7 @@ fn build_request(scope: ProjectBuildScope) -> ProjectBuildRequest { cwd: None, adapter: ProjectBuildAdapter::Auto, scope: Some(scope), + dependency_policy: None, } } @@ -21,6 +22,7 @@ fn validation_request(scope: ProjectValidationScope) -> ProjectValidationRequest action: ProjectValidationAction::Check, adapter: ProjectValidationAdapter::Auto, scope: Some(scope), + dependency_policy: None, test: None, } } diff --git a/crates/webcodex-core/src/project_validation.rs b/crates/webcodex-core/src/project_validation.rs index 0ab7ac055..b0f79fb38 100644 --- a/crates/webcodex-core/src/project_validation.rs +++ b/crates/webcodex-core/src/project_validation.rs @@ -3,7 +3,9 @@ use crate::project_operation::ProjectOperationScopeError; use serde::{Deserialize, Serialize}; /// Portable project-operation scope retained under the project_validate API name. -pub use crate::project_operation::ProjectOperationScope as ProjectValidationScope; +pub use crate::project_operation::{ + ProjectDependencyMode, ProjectDependencyPolicy, ProjectOperationScope as ProjectValidationScope, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] @@ -65,6 +67,8 @@ pub struct ProjectValidationRequest { #[serde(default, skip_serializing_if = "Option::is_none")] pub scope: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub dependency_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub test: Option, } impl ProjectValidationRequest { @@ -105,6 +109,9 @@ impl ProjectValidationRequest { } })?; } + if self.dependency_policy.is_some() && self.action == ProjectValidationAction::FormatCheck { + return Err("dependency_policy requires project_validate action=check or test".into()); + } if let Some(test) = &self.test { if self.action != ProjectValidationAction::Test { return Err("test options require project_validate action=test".into()); diff --git a/crates/webcodex-core/src/project_validation_tests.rs b/crates/webcodex-core/src/project_validation_tests.rs index 4f1d1ff09..41b6e8d27 100644 --- a/crates/webcodex-core/src/project_validation_tests.rs +++ b/crates/webcodex-core/src/project_validation_tests.rs @@ -9,6 +9,7 @@ fn project_validation_protocol_is_closed_declarative_and_roundtrips() { action: ProjectValidationAction::Check, adapter: ProjectValidationAdapter::Auto, scope: None, + dependency_policy: None, test: None, }; let wire = RunnerRequest::from_operation( @@ -201,6 +202,7 @@ fn project_validation_package_scope_roundtrips_and_is_bounded() { scope: Some(ProjectValidationScope { packages: vec!["package-a".into(), "package-b".into()], }), + dependency_policy: None, test: None, }; assert!(input.validate().is_ok()); @@ -219,3 +221,22 @@ fn project_validation_package_scope_roundtrips_and_is_bounded() { assert!(invalid.validate().is_err()); } } + +#[test] +fn project_validation_locked_policy_rejects_format_check_without_silent_ignore() { + let request = ProjectValidationRequest { + project_id: "demo".into(), + cwd: None, + action: ProjectValidationAction::FormatCheck, + adapter: ProjectValidationAdapter::Rust, + scope: None, + dependency_policy: Some(ProjectDependencyPolicy { + mode: ProjectDependencyMode::Locked, + }), + test: None, + }; + assert_eq!( + request.validate().unwrap_err(), + "dependency_policy requires project_validate action=check or test" + ); +} diff --git a/crates/webcodex-core/src/runner_operation.rs b/crates/webcodex-core/src/runner_operation.rs index defecf259..d4badf749 100644 --- a/crates/webcodex-core/src/runner_operation.rs +++ b/crates/webcodex-core/src/runner_operation.rs @@ -2695,6 +2695,7 @@ mod tests { cwd: None, adapter: crate::project_build::ProjectBuildAdapter::Rust, scope: None, + dependency_policy: None, }; let build_process = crate::project_build::canonical_project_build_process("rust", &build_request).unwrap(); diff --git a/crates/webcodex-core/src/runner_protocol.rs b/crates/webcodex-core/src/runner_protocol.rs index 70b386f66..672b7575a 100644 --- a/crates/webcodex-core/src/runner_protocol.rs +++ b/crates/webcodex-core/src/runner_protocol.rs @@ -535,6 +535,13 @@ runner_capabilities! { #[serde(default, skip_serializing_if = "is_false")] pub project_build_v1: bool = false; } + /// Additive portable dependency policy for project build/validation gateways. + /// Missing on older Runners is false; policy-bearing plans must fail closed. + ProjectDependencyPolicy => RUNNER_CAPABILITY_PROJECT_DEPENDENCY_POLICY("project_dependency_policy_v1"), + v2_baseline = false { + #[serde(default, skip_serializing_if = "is_false")] + pub project_dependency_policy_v1: bool = false; + } /// Go project lifecycle gateways execute in Runner-owned single-module mode. /// The Runner forces GO111MODULE=on and GOWORK=off after shell/profile environment /// preparation so ambient module/workspace mode cannot change the planned graph. @@ -2748,6 +2755,7 @@ mod envelope_tests { structured_go_test_json: true, project_validation_v1: false, project_build_v1: false, + project_dependency_policy_v1: false, project_go_single_module_v1: false, project_validation_package_scope_v1: false, project_validation_test_options_v1: false, @@ -4422,6 +4430,14 @@ mod filter_canonical_tests { assert!(step(&["test", "-json", "./pkg"]).is_canonical()); assert!(step(&["test", "-json", ".", "./pkg", "./internal/..."]).is_canonical()); assert!(step(&["test", "-json", "-run", "TestOne", "./..."]).is_canonical()); + assert!(step(&["test", "-json", "-mod=readonly", "./..."]).is_canonical()); + assert!( + step(&["test", "-json", "-mod=readonly", "-run", "TestOne", "./...",]).is_canonical() + ); + assert!(!step(&["test", "-json", "-mod=mod", "./..."]).is_canonical()); + assert!( + !step(&["test", "-json", "-mod=readonly", "-mod=readonly", "./...",]).is_canonical() + ); assert!(!step(&["test", "-v", "./..."]).is_canonical()); assert!(!step(&["run", "./..."]).is_canonical()); } @@ -4560,10 +4576,20 @@ mod filter_canonical_tests { let go_vet = validation_step("check", "go", &["vet", "./cmd/...", "./internal"]); assert!(go_vet.is_canonical()); + let locked_cargo_test = validation_step( + "test", + "cargo", + &["test", "--locked", "-p", "package-a", "-p", "package-b"], + ); + assert!(locked_cargo_test.is_canonical()); + let readonly_go_vet = + validation_step("check", "go", &["vet", "-mod=readonly", "./internal"]); + assert!(readonly_go_vet.is_canonical()); for rejected in [ validation_step("check", "go", &["vet", "not-relative"]), validation_step("check", "go", &["vet", "./internal", "--flag"]), + validation_step("check", "go", &["vet", "-mod=mod", "./internal"]), validation_step( "test", "cargo", @@ -4585,6 +4611,8 @@ mod filter_canonical_tests { let accepted = [ vec!["check"], vec!["check", "--all-targets"], + vec!["check", "--locked"], + vec!["check", "--locked", "-p", "my-crate"], vec!["check", "--all-features"], vec!["check", "--no-default-features"], vec!["check", "--all-targets", "--all-features"], @@ -4622,7 +4650,8 @@ mod filter_canonical_tests { vec!["check", "-p", "tab\tvalue"], vec!["check", "-p", "same-crate", "-p", "same-crate"], vec!["check", "--manifest-path", "/tmp/Cargo.toml"], - vec!["check", "--locked"], + vec!["check", "--locked", "--locked"], + vec!["check", "--locked=true"], vec!["check", "--", "--all-targets"], ]; let over_long_value = "a".repeat(CARGO_VALUE_MAX_BYTES + 1); @@ -4657,6 +4686,8 @@ mod filter_canonical_tests { vec!["test"], vec!["test", "focused"], vec!["test", "--all-targets"], + vec!["test", "--locked"], + vec!["test", "focused", "--locked"], vec!["test", "--lib"], vec!["test", "--lib", "--all-targets", "--no-run"], vec!["test", "--no-run"], @@ -4684,6 +4715,8 @@ mod filter_canonical_tests { vec!["test", "--features", "nul\0byte"], vec!["test", "--features", "col\tumn"], vec!["test", "--manifest-path", "/tmp/Cargo.toml"], + vec!["test", "--locked", "--locked"], + vec!["test", "--locked=true"], vec!["test", "--", "--all-targets"], ]; for args in rejected { diff --git a/crates/webcodex-core/src/runner_protocol/job.rs b/crates/webcodex-core/src/runner_protocol/job.rs index c0321655c..f38f44b6a 100644 --- a/crates/webcodex-core/src/runner_protocol/job.rs +++ b/crates/webcodex-core/src/runner_protocol/job.rs @@ -273,14 +273,16 @@ impl ShellJobValidationStep { /// Canonical `cargo check` argv: `check` followed by zero or more distinct /// read-only flags (`--all-targets`, `--all-features`, -/// `--no-default-features`) and `--features ` / `-p ` pairs. +/// `--no-default-features`, `--locked`) and `--features ` / +/// `-p ` pairs. fn is_canonical_cargo_check_args(args: &[&str]) -> bool { args.first() == Some(&"check") && is_canonical_cargo_flags(&args[1..], false, true) } /// Canonical `cargo test` argv: the `test` subcommand, an optional libtest /// filter (never a Cargo option), then zero or more distinct read-only flags -/// and `--features ` / repeated `-p ` pairs, optionally including +/// (including `--locked`) and `--features ` / repeated `-p ` +/// pairs, optionally including /// the Cargo test-only `--lib` and `--no-run` selectors. /// /// The flat argv boundary has inherent information loss: `["test", @@ -457,7 +459,15 @@ fn is_canonical_go_vet_args(args: &[&str]) -> bool { if args.len() < 2 || args[0] != "vet" { return false; } - let packages = args[1..] + let package_start = if args.get(1) == Some(&"-mod=readonly") { + 2 + } else { + 1 + }; + if args.len() <= package_start { + return false; + } + let packages = args[package_start..] .iter() .map(|value| (*value).to_string()) .collect::>(); @@ -471,17 +481,20 @@ fn is_canonical_go_test_json_args(args: &[&str]) -> bool { if args.len() < 3 || args[0] != "test" || args[1] != "-json" { return false; } - let package_start = if args.get(2) == Some(&"-run") { - let Some(filter) = args.get(3) else { + let mut index = 2; + if args.get(index) == Some(&"-mod=readonly") { + index += 1; + } + if args.get(index) == Some(&"-run") { + let Some(filter) = args.get(index + 1) else { return false; }; if !matches!(normalize_go_test_filter(filter), Ok(Some(value)) if value == *filter) { return false; } - 4 - } else { - 2 - }; + index += 2; + } + let package_start = index; let packages = args[package_start..] .iter() .map(|value| (*value).to_string()) @@ -510,7 +523,7 @@ fn is_canonical_cargo_flags( let mut iter = args.iter(); while let Some(arg) = iter.next() { let key = match *arg { - "--all-targets" | "--all-features" | "--no-default-features" => *arg, + "--all-targets" | "--all-features" | "--no-default-features" | "--locked" => *arg, "--lib" | "--no-run" if cargo_test => *arg, "--features" => { if !seen.insert(*arg) { diff --git a/crates/webcodex-core/src/validation_identity.rs b/crates/webcodex-core/src/validation_identity.rs index 80469590e..ee2c4b920 100644 --- a/crates/webcodex-core/src/validation_identity.rs +++ b/crates/webcodex-core/src/validation_identity.rs @@ -245,12 +245,14 @@ pub fn structured_validation_target_identity( #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StructuredValidationExecutionContext { GoProjectSingleModuleV1, + ProjectDependencyLockedV1, } impl StructuredValidationExecutionContext { const fn as_str(self) -> &'static str { match self { Self::GoProjectSingleModuleV1 => "go_project_single_module_v1", + Self::ProjectDependencyLockedV1 => "project_dependency_locked_v1", } } } @@ -436,6 +438,21 @@ mod tests { .unwrap(); assert_eq!(contextual, "target:44a5798459663307bb9b3ea8"); assert_ne!(contextual, base); + let dependency_locked = contextualize_structured_validation_target_identity( + &base, + StructuredValidationExecutionContext::ProjectDependencyLockedV1, + ) + .unwrap(); + assert_ne!(dependency_locked, base); + assert_ne!(dependency_locked, contextual); + assert_eq!( + dependency_locked, + contextualize_structured_validation_target_identity( + &base, + StructuredValidationExecutionContext::ProjectDependencyLockedV1, + ) + .unwrap() + ); assert!(contextualize_structured_validation_target_identity( "target:short", StructuredValidationExecutionContext::GoProjectSingleModuleV1, diff --git a/crates/webcodex-runner-config/src/lib.rs b/crates/webcodex-runner-config/src/lib.rs index 712b77b46..eea98dd61 100644 --- a/crates/webcodex-runner-config/src/lib.rs +++ b/crates/webcodex-runner-config/src/lib.rs @@ -280,6 +280,7 @@ pub fn generated_runner_config_toml(opts: &RunnerInitOptions) -> Result, ) -> Result<(String, oneshot::Receiver), String> { payload.validate()?; + let requires_dependency_policy = payload.dependency_policy.is_some(); let request_id = next_request_id(); let (tx, rx) = oneshot::channel(); let request = encode_runner_operation( @@ -2580,6 +2581,16 @@ impl RunnerRegistry { "capability_unavailable: upgrade target Runner for project_build_v1".into(), ); } + if requires_dependency_policy + && !runner + .runner_features + .supports(RunnerFeature::ProjectDependencyPolicy) + { + return Err( + "capability_unavailable: upgrade target Runner for project_dependency_policy_v1" + .into(), + ); + } enqueue_pending_request_locked( self.telemetry.as_ref(), &mut inner, @@ -2603,6 +2614,7 @@ impl RunnerRegistry { ) -> Result<(String, oneshot::Receiver), String> { payload.validate()?; let requires_package_scope = payload.scope.is_some(); + let requires_dependency_policy = payload.dependency_policy.is_some(); let requires_test_options = payload.test.is_some(); let request_id = next_request_id(); let (tx, rx) = oneshot::channel(); @@ -2624,6 +2636,16 @@ impl RunnerRegistry { "capability_unavailable: upgrade target Runner for project_validation_v1".into(), ); } + if requires_dependency_policy + && !runner + .runner_features + .supports(RunnerFeature::ProjectDependencyPolicy) + { + return Err( + "capability_unavailable: upgrade target Runner for project_dependency_policy_v1" + .into(), + ); + } if requires_test_options && !runner .runner_features diff --git a/crates/webcodex-runner-registry/src/tests.rs b/crates/webcodex-runner-registry/src/tests.rs index 7ff6cdfc2..4fa2d34c8 100644 --- a/crates/webcodex-runner-registry/src/tests.rs +++ b/crates/webcodex-runner-registry/src/tests.rs @@ -356,6 +356,8 @@ mod plugin_gateway; mod polling; #[path = "tests/project_build.rs"] mod project_build; +#[path = "tests/project_dependency_policy.rs"] +mod project_dependency_policy; #[path = "tests/project_file_read.rs"] mod project_file_read; #[path = "tests/project_inventory.rs"] diff --git a/crates/webcodex-runner-registry/src/tests/project_build.rs b/crates/webcodex-runner-registry/src/tests/project_build.rs index 10835c4c4..d6f9ed722 100644 --- a/crates/webcodex-runner-registry/src/tests/project_build.rs +++ b/crates/webcodex-runner-registry/src/tests/project_build.rs @@ -11,6 +11,7 @@ fn build_request() -> ProjectBuildRequest { cwd: None, adapter: ProjectBuildAdapter::Rust, scope: None, + dependency_policy: None, } } @@ -36,6 +37,7 @@ fn go_build_request() -> ProjectBuildRequest { cwd: None, adapter: ProjectBuildAdapter::Go, scope: None, + dependency_policy: None, } } diff --git a/crates/webcodex-runner-registry/src/tests/project_dependency_policy.rs b/crates/webcodex-runner-registry/src/tests/project_dependency_policy.rs new file mode 100644 index 000000000..5c77bb5a4 --- /dev/null +++ b/crates/webcodex-runner-registry/src/tests/project_dependency_policy.rs @@ -0,0 +1,239 @@ +use super::*; +use webcodex_core::project_build::{ + canonical_project_build_process, project_build_invocation_digest, ProjectBuildAdapter, + ProjectBuildPlan, ProjectBuildProvenance, ProjectBuildRequest, +}; +use webcodex_core::project_validation::{ + ProjectDependencyMode, ProjectDependencyPolicy, ProjectValidationAction, + ProjectValidationAdapter, ProjectValidationProvenance, ProjectValidationRequest, +}; + +fn locked_policy() -> ProjectDependencyPolicy { + ProjectDependencyPolicy { + mode: ProjectDependencyMode::Locked, + } +} + +fn registration(supported: bool) -> RunnerRegisterRequest { + let mut capabilities = + crate::test_support::current_runner_capabilities(RunnerCapabilities::default()); + capabilities.project_build_v1 = true; + capabilities.project_validation_v1 = true; + capabilities.project_dependency_policy_v1 = supported; + current_runner_registration(RunnerRegisterRequest { + computer_session_availability: None, + process_started_at: None, + build: None, + job_concurrency_limit: None, + job_inventory: None, + coding_agent_providers: None, + coding_agent_inventory: None, + client_id: "policy-runner".into(), + runner_instance_id: "inst".into(), + runner_protocol_generation: RUNNER_PROTOCOL_GENERATION_V2, + display_name: None, + owner: None, + hostname: None, + host_context: None, + capabilities, + policy: None, + }) +} + +fn locked_build_request() -> ProjectBuildRequest { + ProjectBuildRequest { + project_id: "demo".into(), + cwd: None, + adapter: ProjectBuildAdapter::Rust, + scope: None, + dependency_policy: Some(locked_policy()), + } +} + +fn locked_validation_request() -> ProjectValidationRequest { + ProjectValidationRequest { + project_id: "demo".into(), + cwd: None, + action: ProjectValidationAction::Check, + adapter: ProjectValidationAdapter::Rust, + scope: None, + dependency_policy: Some(locked_policy()), + test: None, + } +} + +fn locked_build_plan() -> ProjectBuildPlan { + let request = locked_build_request(); + let process = canonical_project_build_process("rust", &request).unwrap(); + ProjectBuildPlan { + provenance: ProjectBuildProvenance { + request, + backend: "rust".into(), + recipe_root: ".".into(), + root_digest: "1".repeat(64), + manifest_digest: "2".repeat(64), + invocation_digest: project_build_invocation_digest(&process), + }, + process, + } +} + +fn locked_validation_metadata() -> ShellJobStartMetadata { + let request = locked_validation_request(); + let step = ShellJobValidationStep { + name: "check".into(), + program: "cargo".into(), + args: vec!["check".into(), "--locked".into(), "--all-targets".into()], + env: Vec::new(), + }; + let validation = ShellJobValidationMetadata { + project_validation: Some(ProjectValidationProvenance { + request, + backend: "rust".into(), + recipe_root: ".".into(), + root_digest: "a".repeat(64), + manifest_digest: "b".repeat(64), + invocation_digest: "c".repeat(64), + }), + source_fence: None, + tool: "project_validate".into(), + kind: "check".into(), + steps: vec![step.clone()], + effective_timeout_secs: 600, + sync_wait_secs: 10, + adapter: "cargo_check".into(), + validation_target_id: Some("target:0123456789abcdef01234567".into()), + minimum_tests: None, + require_tests: None, + no_run: None, + }; + assert!(validation.is_valid()); + ShellJobStartMetadata { + project_id: Some("agent:policy-runner:demo".into()), + project_cwd: Some(".".into()), + purpose: Some("validation".into()), + shell: Some("direct_argv".into()), + validation_steps: vec![step], + validation: Some(validation), + visibility: ShellJobVisibility::Public, + ..Default::default() + } +} + +fn start_request(command: &str) -> ShellJobOpRequest { + ShellJobOpRequest { + login: false, + op: "start".into(), + client_id: Some("policy-runner".into()), + cwd: Some("/tmp/demo".into()), + command: Some(command.to_string()), + timeout_secs: Some(60), + job_id: None, + since_stdout_line: None, + since_stderr_line: None, + tail_lines: None, + limit: None, + codex: None, + } +} + +#[tokio::test] +async fn dependency_policy_is_fenced_before_project_build_planning() { + for supported in [false, true] { + let registry = RunnerRegistry::default(); + registry.register(registration(supported)).await.unwrap(); + let access = auth_context(None, true); + let result = registry + .enqueue_project_build_plan( + "policy-runner".into(), + locked_build_request(), + Some(&access), + ) + .await; + if supported { + assert!(result.is_ok(), "{result:?}"); + } else { + let error = result.unwrap_err(); + assert!(error.contains("project_dependency_policy_v1"), "{error}"); + } + } +} + +#[tokio::test] +async fn dependency_policy_is_fenced_before_project_validation_planning() { + for supported in [false, true] { + let registry = RunnerRegistry::default(); + registry.register(registration(supported)).await.unwrap(); + let access = auth_context(None, true); + let result = registry + .enqueue_project_validation_plan( + "policy-runner".into(), + locked_validation_request(), + Some(&access), + ) + .await; + if supported { + assert!(result.is_ok(), "{result:?}"); + } else { + let error = result.unwrap_err(); + assert!(error.contains("project_dependency_policy_v1"), "{error}"); + } + } +} + +#[tokio::test] +async fn dependency_policy_is_fenced_again_at_project_build_job_admission() { + for supported in [false, true] { + let registry = RunnerRegistry::default(); + registry.register(registration(supported)).await.unwrap(); + let plan = locked_build_plan(); + let result = registry + .start_job_with_metadata_for_access( + start_request(""), + "test".into(), + ShellJobStartMetadata { + project_id: Some("agent:policy-runner:demo".into()), + project_cwd: Some(".".into()), + purpose: Some("build".into()), + shell: Some("direct_argv".into()), + visibility: ShellJobVisibility::Public, + structured_execution: Some(StructuredJobExecution::ProjectBuild(plan)), + ..Default::default() + }, + None, + None, + ) + .await; + if supported { + assert!(result.is_ok(), "{result:?}"); + } else { + let error = result.unwrap_err(); + assert!(error.contains("project_dependency_policy_v1"), "{error}"); + assert!(registry.list_jobs(Some(10)).await.is_empty()); + } + } +} + +#[tokio::test] +async fn dependency_policy_is_fenced_again_at_project_validation_job_admission() { + for supported in [false, true] { + let registry = RunnerRegistry::default(); + registry.register(registration(supported)).await.unwrap(); + let result = registry + .start_job_with_metadata_for_access( + start_request("cargo check --locked --all-targets"), + "test".into(), + locked_validation_metadata(), + None, + None, + ) + .await; + if supported { + assert!(result.is_ok(), "{result:?}"); + } else { + let error = result.unwrap_err(); + assert!(error.contains("project_dependency_policy_v1"), "{error}"); + assert!(registry.list_jobs(Some(10)).await.is_empty()); + } + } +} diff --git a/crates/webcodex-runner-registry/src/tests/reconciliation.rs b/crates/webcodex-runner-registry/src/tests/reconciliation.rs index 96dc0cb4b..4ec1be3e6 100644 --- a/crates/webcodex-runner-registry/src/tests/reconciliation.rs +++ b/crates/webcodex-runner-registry/src/tests/reconciliation.rs @@ -192,6 +192,7 @@ fn go_project_validation_start_metadata() -> ShellJobStartMetadata { action: ProjectValidationAction::Check, adapter: ProjectValidationAdapter::Go, scope: None, + dependency_policy: None, test: None, }, backend: "go".into(), @@ -683,7 +684,7 @@ async fn project_test_options_are_fenced_again_at_job_admission() { validation.tool = "project_validate".into(); validation.project_validation = Some(ProjectValidationProvenance { request:ProjectValidationRequest {project_id:"demo".into(),cwd:None,action:ProjectValidationAction::Test, - adapter:ProjectValidationAdapter::Rust,scope:None, + adapter:ProjectValidationAdapter::Rust,scope:None,dependency_policy:None, test:Some(ProjectValidationTestOptions {filter:Some("focused".into()),min_tests:Some(3),require_tests:None})}, backend:"rust".into(),recipe_root:".".into(),root_digest:"a".repeat(64),manifest_digest:"b".repeat(64),invocation_digest:"c".repeat(64), }); diff --git a/crates/webcodex-runner/src/main.rs b/crates/webcodex-runner/src/main.rs index 562a59eb1..f320a657e 100644 --- a/crates/webcodex-runner/src/main.rs +++ b/crates/webcodex-runner/src/main.rs @@ -1539,6 +1539,7 @@ fn runner_register_capabilities(cfg: &RunnerConfig) -> RunnerCapabilities { capabilities.set(RunnerCapabilityId::StructuredGoTestJson, true); capabilities.set(RunnerCapabilityId::ProjectValidation, true); capabilities.set(RunnerCapabilityId::ProjectBuild, true); + capabilities.set(RunnerCapabilityId::ProjectDependencyPolicy, true); // Go project gateways pin GO111MODULE=on and GOWORK=off after prepared shell/profile env is // applied. Keep this independent for mixed Server/Runner rolling upgrades. capabilities.set(RunnerCapabilityId::ProjectGoSingleModule, true); diff --git a/crates/webcodex-runner/src/webcodex_runner/job_manager_tests/project_build.rs b/crates/webcodex-runner/src/webcodex_runner/job_manager_tests/project_build.rs index 61250448e..1a0ada042 100644 --- a/crates/webcodex-runner/src/webcodex_runner/job_manager_tests/project_build.rs +++ b/crates/webcodex-runner/src/webcodex_runner/job_manager_tests/project_build.rs @@ -59,6 +59,7 @@ edition = "2021" cwd: member_cwd.then(|| "member".into()), adapter: ProjectBuildAdapter::Auto, scope: None, + dependency_policy: None, }; let (plan, cwd) = crate::webcodex_runner::project_build::plan(&policy, ®istry, &request).unwrap(); @@ -199,6 +200,7 @@ fn go_project_build_overrides_ambient_gowork_at_spawn() { cwd: None, adapter: ProjectBuildAdapter::Go, scope: None, + dependency_policy: None, }; let (plan, cwd) = crate::webcodex_runner::project_build::plan(&policy, ®istry, &request).unwrap(); diff --git a/crates/webcodex-runner/src/webcodex_runner/job_manager_tests/project_validation.rs b/crates/webcodex-runner/src/webcodex_runner/job_manager_tests/project_validation.rs index 746d1c621..9af438a6d 100644 --- a/crates/webcodex-runner/src/webcodex_runner/job_manager_tests/project_validation.rs +++ b/crates/webcodex-runner/src/webcodex_runner/job_manager_tests/project_validation.rs @@ -64,6 +64,7 @@ edition = "2021" action: ProjectValidationAction::Check, adapter: ProjectValidationAdapter::Auto, scope: None, + dependency_policy: None, test: None, }; let (plan, cwd) = @@ -240,6 +241,7 @@ fn go_project_validation_overrides_ambient_gowork_but_direct_go_test_does_not() action: ProjectValidationAction::Check, adapter: ProjectValidationAdapter::Go, scope: None, + dependency_policy: None, test: None, }; let (plan, cwd) = diff --git a/crates/webcodex-runner/src/webcodex_runner/project_build_tests.rs b/crates/webcodex-runner/src/webcodex_runner/project_build_tests.rs index 8d6a72688..dc54f2264 100644 --- a/crates/webcodex-runner/src/webcodex_runner/project_build_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/project_build_tests.rs @@ -34,6 +34,41 @@ fn request() -> ProjectBuildRequest { cwd: None, adapter: ProjectBuildAdapter::Auto, scope: None, + dependency_policy: None, + } +} + +#[test] +fn project_build_runner_plans_locked_dependency_policy_for_rust_and_go() { + for (marker, adapter, expected) in [ + ( + "Cargo.toml", + ProjectBuildAdapter::Rust, + vec!["build", "--locked"], + ), + ( + "go.mod", + ProjectBuildAdapter::Go, + vec!["build", "-mod=readonly", "./..."], + ), + ] { + let (_tmp, _root, registry, policy) = fixture(marker); + let request = ProjectBuildRequest { + project_id: "demo".into(), + cwd: None, + adapter, + scope: None, + dependency_policy: Some(ProjectDependencyPolicy { + mode: ProjectDependencyMode::Locked, + }), + }; + let (plan, _) = project_build::plan(&policy, ®istry, &request).unwrap(); + assert_eq!(plan.provenance.request, request); + assert_eq!(plan.process.args, expected); + assert_eq!( + plan.provenance.invocation_digest, + project_build_invocation_digest(&plan.process) + ); } } diff --git a/crates/webcodex-runner/src/webcodex_runner/validation/project.rs b/crates/webcodex-runner/src/webcodex_runner/validation/project.rs index d32279e15..35d169658 100644 --- a/crates/webcodex-runner/src/webcodex_runner/validation/project.rs +++ b/crates/webcodex-runner/src/webcodex_runner/validation/project.rs @@ -7,7 +7,7 @@ use webcodex_core::validation_identity::{ }; use webcodex_validation::{ detect_validation_recipe, project_validation_operation, - resolve_validation_recipe_with_packages, RecipeId, SemanticCheck, + resolve_validation_recipe_with_project_policy, RecipeId, SemanticCheck, }; pub(crate) fn plan( @@ -51,10 +51,11 @@ pub(crate) fn plan( .as_ref() .and_then(|test| test.filter.as_deref()); let operation = project_validation_operation(backend.as_str(), action, packages) + .and_then(|operation| operation.with_dependency_policy(request.dependency_policy)) .and_then(|operation| operation.with_test_filter(filter)) .map_err(|code| unavailable(code, Some(backend.as_str())))?; let adapter = operation.adapter(); - let resolved = resolve_validation_recipe_with_packages( + let resolved = resolve_validation_recipe_with_project_policy( &root, request.cwd.as_deref(), hint, @@ -64,6 +65,7 @@ pub(crate) fn plan( .scope .as_ref() .map(|scope| scope.packages.as_slice()), + request.dependency_policy, ) .map_err(|e| unavailable(e.code, Some(backend.as_str())))?; let identity = operation diff --git a/crates/webcodex-runner/src/webcodex_runner/validation/project_tests.rs b/crates/webcodex-runner/src/webcodex_runner/validation/project_tests.rs index 7bd26e653..5ba0836dd 100644 --- a/crates/webcodex-runner/src/webcodex_runner/validation/project_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/validation/project_tests.rs @@ -30,9 +30,46 @@ fn request(action: ProjectValidationAction) -> ProjectValidationRequest { action, adapter: ProjectValidationAdapter::Auto, scope: None, + dependency_policy: None, test: None, } } + +#[test] +fn project_validation_runner_plans_locked_dependency_policy_for_rust_and_go() { + for (marker, adapter, expected) in [ + ( + "Cargo.toml", + ProjectValidationAdapter::Rust, + vec!["check", "--locked", "--all-targets"], + ), + ( + "go.mod", + ProjectValidationAdapter::Go, + vec!["vet", "-mod=readonly", "./..."], + ), + ] { + let (_tmp, _root, registry, policy) = fixture(marker); + let mut locked = request(ProjectValidationAction::Check); + locked.adapter = adapter; + locked.dependency_policy = Some(ProjectDependencyPolicy { + mode: ProjectDependencyMode::Locked, + }); + let (plan, _) = project::plan(&policy, ®istry, &locked).unwrap(); + assert_eq!(plan.provenance.request, locked); + assert_eq!(plan.step.args, expected); + + let mut default = locked.clone(); + default.dependency_policy = None; + let (default_plan, _) = project::plan(&policy, ®istry, &default).unwrap(); + assert_ne!(plan.validation_target_id, default_plan.validation_target_id); + assert_ne!( + plan.provenance.invocation_digest, + default_plan.provenance.invocation_digest + ); + } +} + #[test] fn project_validation_runner_resolves_all_production_actions() { use sha2::Digest; diff --git a/crates/webcodex-tool-contracts/src/tests/input_schemas.rs b/crates/webcodex-tool-contracts/src/tests/input_schemas.rs index 8bb71e082..44c461426 100644 --- a/crates/webcodex-tool-contracts/src/tests/input_schemas.rs +++ b/crates/webcodex-tool-contracts/src/tests/input_schemas.rs @@ -389,6 +389,39 @@ fn project_validate_package_scope_schema_is_closed_and_bounded() { } } +#[test] +fn project_dependency_policy_schema_is_closed_and_locked_only() { + for (tool, mut base) in [ + ("project_build", serde_json::json!({"project":"demo"})), + ( + "project_validate", + serde_json::json!({"project":"demo","action":"check"}), + ), + ] { + let schema = input_schema_for_tool(tool); + base["dependency_policy"] = serde_json::json!({"mode":"locked"}); + assert!( + test_support::validate_schema_instance(&base, &schema).is_ok(), + "{tool}: {base}" + ); + assert!(ToolCall::from_tool_name(tool, base.clone()).is_ok()); + + for policy in [ + serde_json::json!({}), + serde_json::json!({"mode":"update"}), + serde_json::json!({"mode":"locked","offline":false}), + ] { + let mut invalid = base.clone(); + invalid["dependency_policy"] = policy; + assert!( + test_support::validate_schema_instance(&invalid, &schema).is_err(), + "{tool}: {invalid}" + ); + assert!(ToolCall::from_tool_name(tool, invalid).is_err()); + } + } +} + #[test] fn execution_timeout_schemas_keep_runtime_bounds_and_hide_sync_wait_tuning() { let specs = registered_tool_specs(); diff --git a/crates/webcodex-tool-contracts/src/tool_call.rs b/crates/webcodex-tool-contracts/src/tool_call.rs index cfb60b49a..ed3615997 100644 --- a/crates/webcodex-tool-contracts/src/tool_call.rs +++ b/crates/webcodex-tool-contracts/src/tool_call.rs @@ -2883,6 +2883,10 @@ pub enum ToolCall { /// Optional bounded Cargo package selectors or project-relative Go package patterns. #[serde(default)] scope: Option, + /// Optional portable dependency-resolution policy. locked forbids adapters from + /// repairing dependency selection state; it does not imply offline execution. + #[serde(default)] + dependency_policy: Option, /// Total build execution budget, default 1800 seconds, clamped to 7 days. /// Host handoff timing never extends this budget or starts a second build. #[serde(default)] @@ -2909,6 +2913,10 @@ pub enum ToolCall { /// scope is not supported. #[serde(default)] scope: Option, + /// Optional portable dependency-resolution policy. locked forbids adapters from + /// repairing dependency selection state; it does not imply offline execution. + #[serde(default)] + dependency_policy: Option, /// Test-only selector and count postconditions. Rust uses a libtest substring; /// Go uses native -run regexp. Omission preserves unfiltered positive-test proof. #[serde(default)] diff --git a/crates/webcodex-tool-contracts/src/tool_catalog.rs b/crates/webcodex-tool-contracts/src/tool_catalog.rs index d4a199f27..193b960f4 100644 --- a/crates/webcodex-tool-contracts/src/tool_catalog.rs +++ b/crates/webcodex-tool-contracts/src/tool_catalog.rs @@ -197,7 +197,7 @@ pub const TOOL_RECOMMENDED_FLOWS: &[ToolRecommendedFlow] = &[ summary: "Build: prefer project_build for portable Rust/Go builds; use native execution only outside the canonical gateway.", manifest_purpose: - "Use project_build for canonical Rust cargo build and Go go build with optional bounded package scope. It preserves Runner-owned recipe resolution, provenance, and same-execution Job admission. Use run_process only when the required build is outside this closed contract.", + "Use project_build for canonical Rust cargo build and Go go build with optional bounded package scope. dependency_policy.mode=locked prevents adapter-managed dependency selection updates without implying offline execution. It preserves Runner-owned recipe resolution, provenance, and same-execution Job admission. Use run_process only when the required build is outside this closed contract.", tools: &["project_build", "observe_jobs", "run_process"], }, ToolRecommendedFlow { @@ -205,7 +205,7 @@ pub const TOOL_RECOMMENDED_FLOWS: &[ToolRecommendedFlow] = &[ summary: "Validate: use structured validators when their canonical diagnostics, evidence/test-count, validation identity, or same-execution Job semantics help; native execution is first-class when the command is outside or awkward for that contract.", manifest_purpose: - "For portable project validation, prefer project_validate. Use cargo_fmt/cargo_check/cargo_test/go_test for advanced ecosystem options when their canonical argv, parsed diagnostics, validation identity, test-count proof, min_tests/require_tests, bounded projection, or same-execution Job handoff materially helps. Native validation is first-class when the command is outside or awkward for that structured contract: prefer run_process for one literal-argv executable, run_shell when shell grammar/output shaping is required, and run_script for program-like supported scripts. Keep independent failure/permission boundaries separate. cargo_fmt check=false retains ensure-format mutation truth; check=true stays read-only.", + "For portable project validation, prefer project_validate; dependency_policy.mode=locked prevents adapter-managed dependency selection updates for check/test without implying offline execution. Use cargo_fmt/cargo_check/cargo_test/go_test for advanced ecosystem options when their canonical argv, parsed diagnostics, validation identity, test-count proof, min_tests/require_tests, bounded projection, or same-execution Job handoff materially helps. Native validation is first-class when the command is outside or awkward for that structured contract: prefer run_process for one literal-argv executable, run_shell when shell grammar/output shaping is required, and run_script for program-like supported scripts. Keep independent failure/permission boundaries separate. cargo_fmt check=false retains ensure-format mutation truth; check=true stays read-only.", tools: &[ "project_validate", "cargo_fmt", diff --git a/crates/webcodex-tool-contracts/src/tool_definition/jobs.rs b/crates/webcodex-tool-contracts/src/tool_definition/jobs.rs index 482adda4e..6df020893 100644 --- a/crates/webcodex-tool-contracts/src/tool_definition/jobs.rs +++ b/crates/webcodex-tool-contracts/src/tool_definition/jobs.rs @@ -50,7 +50,7 @@ pub(super) const EXECUTION_DEFINITIONS: &[ToolDefinition] = &[ false, super::ToolSessionEvidencePolicy::NONE, ), - "Preferred portable project build gateway. Runner resolves the nearest unambiguous Rust/Go recipe and executes canonical cargo build or go build argv. Go project builds run in Runner-owned single-module mode (GO111MODULE=on, GOWORK=off), so ambient module/workspace mode cannot change the planned graph. Optional scope.packages narrows Cargo with repeated -p selectors or Go with bounded project-relative patterns. Long builds remain the same durable Job; pending never authorizes retry.", + "Preferred portable project build gateway. Runner resolves the nearest unambiguous Rust/Go recipe and executes canonical cargo build or go build argv. Go project builds run in Runner-owned single-module mode (GO111MODULE=on, GOWORK=off), so ambient module/workspace mode cannot change the planned graph. Optional scope.packages narrows Cargo with repeated -p selectors or Go with bounded project-relative patterns. dependency_policy.mode=locked forbids adapters from repairing dependency selection state (Cargo --locked; Go -mod=readonly); it does not imply offline execution. Long builds remain the same durable Job; pending never authorizes retry.", ) .with_execution(super::ToolExecutionContract::new( super::ToolExecutionForm::ProjectBuild, diff --git a/crates/webcodex-tool-contracts/src/tool_definition/testing.rs b/crates/webcodex-tool-contracts/src/tool_definition/testing.rs index e5c1a5a3f..708bc03cb 100644 --- a/crates/webcodex-tool-contracts/src/tool_definition/testing.rs +++ b/crates/webcodex-tool-contracts/src/tool_definition/testing.rs @@ -136,7 +136,7 @@ pub(super) const DEFINITIONS: &[ToolDefinition] = &[ false, super::ToolSessionEvidencePolicy::NONE.validation_identity(super::ToolValidationIdentityKind::Project), ), - "Preferred portable project validation with Runner-owned recipe/argv. action=format_check/check/test; auto or matching Rust/Go adapter, Node/Python unavailable. Rust maps to cargo fmt -- --check, cargo check --all-targets, or cargo test; Go to go vet or go test -json in Runner-owned single-module mode (GO111MODULE=on, GOWORK=off), so ambient module/workspace mode does not change project_validate semantics. scope.packages (1..8) narrows check/test; scoped formatting is unsupported. For action=test, test.filter is a Rust substring or Go -run regexp, never flags; test.require_tests defaults true and test.min_tests sets a proven minimum. false accepts proven zero only without a minimum; unknown counts are not zero. Scope/test options need additive Runner support. No arbitrary executable, argv, shell or source-format mutation. Long work returns the same Job; observe it, never retry pending. Source changes stale evidence; source_state does not certify current source. Advanced options remain on cargo_* / go_test.", ) + "Preferred portable project validation. Runner owns recipe and canonical argv. action=format_check/check/test; Rust/Go supported, Node/Python unavailable. scope.packages (1..8) narrows check/test; scoped formatting fails closed. action=test accepts bounded native test.filter plus require_tests/min_tests evidence policy. dependency_policy.mode=locked maps to Cargo --locked or Go -mod=readonly; it does not imply offline and is unsupported for format_check. Additive scope/test/dependency-policy semantics require Runner support. Go runs in Runner-owned single-module mode (GO111MODULE=on, GOWORK=off). No arbitrary executable, argv, shell, or source mutation. Long work keeps the same Job; observe pending, never retry. Advanced ecosystem options remain on cargo_* / go_test.", ) .with_execution(super::ToolExecutionContract::new( super::ToolExecutionForm::StructuredValidation, super::ToolExecutionLifetime::Runner, diff --git a/crates/webcodex-validation/src/adapters/go.rs b/crates/webcodex-validation/src/adapters/go.rs index 8214a5657..7f3613299 100644 --- a/crates/webcodex-validation/src/adapters/go.rs +++ b/crates/webcodex-validation/src/adapters/go.rs @@ -57,6 +57,11 @@ impl ValidationAdapter for GoTestValidationAdapter { ValidationPlanArg::Literal("test"), ValidationPlanArg::Literal("-json"), ]; + if options.dependency_mode + == Some(webcodex_core::project_validation::ProjectDependencyMode::Locked) + { + args.push(ValidationPlanArg::Literal("-mod=readonly")); + } if let Some(filter) = options .filter .as_deref() @@ -156,6 +161,11 @@ impl ValidationAdapter for GoVetValidationAdapter { let packages = normalize_go_test_packages(options.go_packages.as_deref()) .map_err(|reason| format!("packages {reason}"))?; let mut args = vec![ValidationPlanArg::Literal("vet")]; + if options.dependency_mode + == Some(webcodex_core::project_validation::ProjectDependencyMode::Locked) + { + args.push(ValidationPlanArg::Literal("-mod=readonly")); + } if explicit_packages { args.extend(packages.into_iter().map(ValidationPlanArg::Value)); } else { diff --git a/crates/webcodex-validation/src/adapters/mod.rs b/crates/webcodex-validation/src/adapters/mod.rs index 6916c9fa1..6abd74075 100644 --- a/crates/webcodex-validation/src/adapters/mod.rs +++ b/crates/webcodex-validation/src/adapters/mod.rs @@ -3,6 +3,7 @@ mod go; mod rust; +use webcodex_core::project_validation::{ProjectDependencyMode, ProjectDependencyPolicy}; use webcodex_core::runner_protocol::ShellJobValidationStep; use webcodex_core::shell_quote::shell_escape_simple; use webcodex_core::validation_evidence::ValidationDiagnostics; @@ -25,6 +26,7 @@ pub struct ValidationCommandOptions { /// First-class `go_test` package scope. Other validation adapters must /// reject this Go-specific option rather than silently ignoring it. pub go_packages: Option>, + pub dependency_mode: Option, } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -35,6 +37,7 @@ pub struct CargoCheckOptions { pub features: Option, pub package: Option, pub packages: Option>, + pub dependency_mode: Option, } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -48,17 +51,20 @@ pub struct CargoTestOptions { pub package: Option, pub packages: Option>, pub no_run: Option, + pub dependency_mode: Option, } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct GoCheckOptions { pub packages: Option>, + pub dependency_mode: Option, } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct GoTestOptions { pub filter: Option, pub packages: Option>, + pub dependency_mode: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -107,6 +113,45 @@ impl ReadOnlyValidationOperation { Ok(self) } + pub fn with_dependency_policy( + mut self, + policy: Option, + ) -> Result { + let Some(policy) = policy else { + return Ok(self); + }; + match &mut self { + Self::Cargo(CargoReadOnlyValidationOperation::FormatCheck) => { + return Err("dependency_policy_unsupported") + } + Self::Cargo(CargoReadOnlyValidationOperation::Check(options)) => { + options.dependency_mode = Some(policy.mode); + } + Self::Cargo(CargoReadOnlyValidationOperation::Test(options)) => { + options.dependency_mode = Some(policy.mode); + } + Self::Go(GoReadOnlyValidationOperation::Check(options)) => { + options.dependency_mode = Some(policy.mode); + } + Self::Go(GoReadOnlyValidationOperation::Test(options)) => { + options.dependency_mode = Some(policy.mode); + } + } + Ok(self) + } + + fn dependency_mode(&self) -> Option { + match self { + Self::Cargo(CargoReadOnlyValidationOperation::FormatCheck) => None, + Self::Cargo(CargoReadOnlyValidationOperation::Check(options)) => { + options.dependency_mode + } + Self::Cargo(CargoReadOnlyValidationOperation::Test(options)) => options.dependency_mode, + Self::Go(GoReadOnlyValidationOperation::Check(options)) => options.dependency_mode, + Self::Go(GoReadOnlyValidationOperation::Test(options)) => options.dependency_mode, + } + } + pub fn compatibility_profile(&self) -> ValidationCompatibilityProfile { match self { Self::Cargo(CargoReadOnlyValidationOperation::FormatCheck) => { @@ -211,10 +256,18 @@ impl ReadOnlyValidationOperation { "filter": options.filter.as_deref(), }), }; - webcodex_core::validation_identity::structured_validation_target_identity( + let identity = webcodex_core::validation_identity::structured_validation_target_identity( profile.validation_identity, &arguments, - ) + )?; + if self.dependency_mode() == Some(ProjectDependencyMode::Locked) { + webcodex_core::validation_identity::contextualize_structured_validation_target_identity( + &identity, + webcodex_core::validation_identity::StructuredValidationExecutionContext::ProjectDependencyLockedV1, + ) + } else { + Some(identity) + } } } @@ -246,7 +299,10 @@ pub fn project_validation_operation( )), ("go", Format) => Err("validation_action_unsupported"), ("go", Check) => Ok(ReadOnlyValidationOperation::Go( - GoReadOnlyValidationOperation::Check(GoCheckOptions { packages }), + GoReadOnlyValidationOperation::Check(GoCheckOptions { + packages, + ..Default::default() + }), )), ("go", Test) => Ok(ReadOnlyValidationOperation::Go( GoReadOnlyValidationOperation::Test(GoTestOptions { @@ -267,6 +323,7 @@ impl From for ValidationCommandOptions { features: options.features, package: options.package, cargo_packages: options.packages, + dependency_mode: options.dependency_mode, ..Self::default() } } @@ -284,6 +341,7 @@ impl From for ValidationCommandOptions { package: options.package, cargo_packages: options.packages, no_run: options.no_run, + dependency_mode: options.dependency_mode, ..Self::default() } } @@ -293,6 +351,7 @@ impl From for ValidationCommandOptions { fn from(options: GoCheckOptions) -> Self { Self { go_packages: options.packages, + dependency_mode: options.dependency_mode, ..Self::default() } } @@ -303,6 +362,7 @@ impl From for ValidationCommandOptions { Self { filter: options.filter, go_packages: options.packages, + dependency_mode: options.dependency_mode, ..Self::default() } } diff --git a/crates/webcodex-validation/src/adapters/rust.rs b/crates/webcodex-validation/src/adapters/rust.rs index c3d7d3900..3d4917317 100644 --- a/crates/webcodex-validation/src/adapters/rust.rs +++ b/crates/webcodex-validation/src/adapters/rust.rs @@ -197,6 +197,11 @@ fn cargo_check_plan(options: ValidationCommandOptions) -> Result Result, package_scope: Option<&[String]>, +) -> Result { + resolve_validation_recipe_with_project_policy( + execution_root, + cwd, + explicit_recipe, + checks, + test_filter, + package_scope, + None, + ) +} + +pub fn resolve_validation_recipe_with_project_policy( + execution_root: &Path, + cwd: Option<&str>, + explicit_recipe: Option, + checks: &[SemanticCheck], + test_filter: Option<&str>, + package_scope: Option<&[String]>, + dependency_policy: Option, ) -> Result { let resolved_root = resolve_project_recipe_root(execution_root, cwd, explicit_recipe) .map_err(map_project_recipe_error)?; @@ -168,8 +188,13 @@ pub fn resolve_validation_recipe_with_packages( read_project_recipe_file(root, &marker_path).map_err(map_project_recipe_error)?; let (steps, provenance_files) = match recipe { RecipeId::Rust | RecipeId::Go => { - let steps = - canonical_adapter_steps(recipe, checks, test_filter.as_deref(), package_scope)?; + let steps = canonical_adapter_steps( + recipe, + checks, + test_filter.as_deref(), + package_scope, + dependency_policy, + )?; let provenance_files = project_recipe_provenance_files(&resolved_root) .map_err(map_project_recipe_error)? .expect("canonical project validation adapters are Rust/Go"); @@ -227,6 +252,7 @@ fn canonical_adapter_steps( checks: &[SemanticCheck], test_filter: Option<&str>, package_scope: Option<&[String]>, + dependency_policy: Option, ) -> Result, RecipeError> { let mut steps = Vec::with_capacity(checks.len()); for check in checks { @@ -243,6 +269,8 @@ fn canonical_adapter_steps( } })?; let operation = operation + .with_dependency_policy(dependency_policy) + .map_err(RecipeError::new)? .with_test_filter( (*check == SemanticCheck::Test) .then_some(test_filter) diff --git a/docs/MCP.md b/docs/MCP.md index 456ef077e..0ee766c65 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -352,7 +352,9 @@ work_on_project For ordinary portable read-only validation, prefer `project_validate`. It accepts only a closed `format_check` / `check` / `test` intent plus an optional `auto` / `rust` / `go` adapter hint; the Runner resolves the nearest unambiguous recipe on its own registered filesystem and then starts the existing structured validation Job. Rust maps to `cargo fmt -- --check`, `cargo check --all-targets`, or `cargo test`; Go maps to `go vet ./...` or `go test -json ./...`. Go project validation runs in Runner-owned single-module mode with `GO111MODULE=on` and `GOWORK=off`, so ambient module mode or parent `go.work` selection cannot silently change the gateway's workspace semantics. Its validation target identity is domain-separated from ambient Go specialist evidence, so success under different workspace semantics cannot reconcile a gateway failure. The standalone `go_test` specialist keeps its existing environment behavior. An optional bounded `scope.packages` (1..8 entries) narrows Rust check/test through repeated Cargo `-p` selectors and Go check/test through project-relative package patterns; package-scoped formatting fails closed. Node/Python detection currently returns a bounded unsupported result. The request never carries arbitrary executable, argv, shell grammar, installation, or source mutation. Existing `cargo_*` / `go_test` tools remain available for ecosystem-specific advanced options. `project_validate` requires the additive `project_validation_v1` Runner capability; scoped requests additionally require `project_validation_package_scope_v1`. Go project-validation Job admission additionally requires `project_go_single_module_v1`, so a Server cannot hand a Go gateway plan to an older Runner that may inherit ambient workspace state. -For ordinary portable Rust/Go builds, prefer `project_build`. It accepts only an exact registered `project`, optional project-relative `cwd`, an optional `auto` / `rust` / `go` adapter hint, optional bounded `scope.packages` (1..8 entries), and total `timeout_secs`. The Runner resolves the nearest unambiguous recipe and owns canonical argv: Rust maps to `cargo build` with repeated `-p` selectors when scoped; Go maps to `go build ./...` or the bounded project-relative package patterns supplied by the caller. Go project builds execute with Runner-owned `GO111MODULE=on` and `GOWORK=off`; full `go.work` workspace semantics are outside the v1 gateway rather than inherited implicitly from the Runner host. The request cannot provide an executable, argv, shell, script, release/profile/target/features, workspace/exclude policy, dependency/network policy, or artifact-discovery contract. Node/Python recipes fail closed as unsupported in v1. +For ordinary portable Rust/Go builds, prefer `project_build`. It accepts only an exact registered `project`, optional project-relative `cwd`, an optional `auto` / `rust` / `go` adapter hint, optional bounded `scope.packages` (1..8 entries), and total `timeout_secs`. The Runner resolves the nearest unambiguous recipe and owns canonical argv: Rust maps to `cargo build` with repeated `-p` selectors when scoped; Go maps to `go build ./...` or the bounded project-relative package patterns supplied by the caller. Go project builds execute with Runner-owned `GO111MODULE=on` and `GOWORK=off`; full `go.work` workspace semantics are outside the v1 gateway rather than inherited implicitly from the Runner host. The request cannot provide an executable, argv, shell, script, release/profile/target/features, workspace/exclude policy, offline/network policy, or artifact-discovery contract. Node/Python recipes fail closed as unsupported in v1. + +Both gateways optionally accept `dependency_policy: {"mode":"locked"}`. This is a portable dependency-resolution guarantee, not a literal cross-ecosystem flag contract: Rust build/check/test use Cargo `--locked`, while Go build/vet/test use `-mod=readonly`. The policy tells the adapter not to repair project dependency selection state in order to make the operation succeed; it does **not** disable registry/module/toolchain network access. Offline/network policy remains a separate #599 extension. `project_validate(action="format_check")` rejects the dependency policy instead of silently ignoring it. Policy-bearing planning and typed Job admission both require the additive `project_dependency_policy_v1` Runner capability. Locked validation derives a distinct durable validation target identity, while requests that omit the policy preserve the historical argv and identity. `project_build` requires the additive `project_build_v1` Runner capability at both planning and typed Job admission. Go project-build Job admission additionally requires `project_go_single_module_v1`. Admission replans the registered project/root, recipe, manifest/lock provenance, package scope, and canonical invocation; the worker rechecks that same plan after any local queue wait, before native process execution. A stale plan fails as `not_started` and releases its Job slot rather than silently rebuilding or executing the outdated intent. Long builds keep the same durable Job and return the ordinary sparse pending continuation; pending never authorizes retry or redispatch. The closed gateway bounds WebCodex's command authority but is not an OS sandbox: Cargo/Go build logic and project build scripts may still have their own filesystem or network effects. Existing lower-level execution tools remain explicit escape hatches for build forms outside this v1 contract. diff --git a/docs/MCP.zh-CN.md b/docs/MCP.zh-CN.md index d9ca1d82a..41b64b0b4 100644 --- a/docs/MCP.zh-CN.md +++ b/docs/MCP.zh-CN.md @@ -271,7 +271,9 @@ work_on_project 普通的 portable read-only validation 优先使用 `project_validate`。它只接受封闭的 `format_check` / `check` / `test` intent,以及可选的 `auto` / `rust` / `go` adapter hint;Runner 在自己注册的真实文件系统上解析最近且无歧义的 recipe,然后进入现有 structured validation Job。Rust 分别映射到 `cargo fmt -- --check`、`cargo check --all-targets`、`cargo test`;Go 映射到 `go vet ./...` 或 `go test -json ./...`。Go project validation 由 Runner 固定为 single-module 模式(`GO111MODULE=on`、`GOWORK=off`),因此 ambient module mode 或父目录 `go.work` 选择不会静默改变 gateway 的 workspace 语义;其 validation target identity 与 ambient Go specialist evidence 做 domain separation,因此不同 workspace 语义下产生的成功不会消解 gateway failure。独立的 `go_test` specialist 保持现有环境语义。可选的有界 `scope.packages`(1..8 项)会把 Rust check/test 映射为重复 Cargo `-p` selector,把 Go check/test 映射为 project-relative package pattern;带 package scope 的格式检查会 fail closed。当前检测到 Node/Python 时会返回有界的 unsupported 结果。请求不会携带 arbitrary executable、argv、shell grammar、安装动作或 source mutation;需要 ecosystem-specific 高级参数时继续使用现有 `cargo_*` / `go_test`。`project_validate` 依赖 additive `project_validation_v1` Runner capability;只有 scoped request 额外要求 `project_validation_package_scope_v1`;Go project-validation Job 准入还额外要求 `project_go_single_module_v1`,因此 Server 不会把 Go gateway plan 交给仍可能继承 ambient workspace 状态的旧 Runner。 -普通的 portable Rust/Go 构建优先使用 `project_build`。它只接受精确 registered `project`、可选的 project-relative `cwd`、可选的 `auto` / `rust` / `go` adapter hint、有界 `scope.packages`(1..8 项)以及总 `timeout_secs`。Runner 解析最近且无歧义的 recipe 并拥有 canonical argv:Rust 映射为 `cargo build`,有 scope 时使用重复 `-p` selector;Go 映射为 `go build ./...` 或调用方给出的有界 project-relative package pattern。Go project build 由 Runner 固定以 `GO111MODULE=on`、`GOWORK=off` 执行;完整 `go.work` workspace 语义不属于 v1 gateway,也不会从 Runner host 隐式继承。请求不能携带 executable、argv、shell、script、release/profile/target/features、workspace/exclude、依赖/网络策略或 artifact discovery contract;v1 检测到 Node/Python recipe 时 fail closed。 +普通的 portable Rust/Go 构建优先使用 `project_build`。它只接受精确 registered `project`、可选的 project-relative `cwd`、可选的 `auto` / `rust` / `go` adapter hint、有界 `scope.packages`(1..8 项)以及总 `timeout_secs`。Runner 解析最近且无歧义的 recipe 并拥有 canonical argv:Rust 映射为 `cargo build`,有 scope 时使用重复 `-p` selector;Go 映射为 `go build ./...` 或调用方给出的有界 project-relative package pattern。Go project build 由 Runner 固定以 `GO111MODULE=on`、`GOWORK=off` 执行;完整 `go.work` workspace 语义不属于 v1 gateway,也不会从 Runner host 隐式继承。请求不能携带 executable、argv、shell、script、release/profile/target/features、workspace/exclude、offline/network 策略或 artifact discovery contract;v1 检测到 Node/Python recipe 时 fail closed。 + +两个 gateway 都可选接受 `dependency_policy: {"mode":"locked"}`。这是 portable 的依赖解析保证,而不是宣称不同生态的原生 flag 完全等价:Rust build/check/test 映射为 Cargo `--locked`,Go build/vet/test 映射为 `-mod=readonly`。它要求 adapter 不得为了让本次操作成功而修复或改写项目级依赖选择状态,但**不**表示关闭 registry/module/toolchain 网络访问;offline/network policy 仍是 #599 后续独立扩展。`project_validate(action="format_check")` 会拒绝该 policy,而不是静默忽略。携带 policy 的 planning 与 typed Job admission 都要求 additive `project_dependency_policy_v1` Runner capability。locked validation 使用独立的 durable validation target identity;省略 policy 的请求保持原有 argv 与 identity。 `project_build` 在 planning 与 typed Job admission 两处都要求 additive `project_build_v1` Runner capability;Go project-build Job 准入还额外要求 `project_go_single_module_v1`。Job 准入会重新规划 registered project/root、recipe、manifest/lock provenance、package scope 与 canonical invocation;若经过本地排队,worker 会在原生进程执行前再次核验同一个计划。计划 stale 时以 `not_started` 拒绝并释放 Job 槽位,不会静默重建或执行过期意图。长构建继续使用同一个 durable Job,并返回普通的 sparse pending continuation;pending 绝不授权 retry/redispatch。这个 closed gateway 限制的是 WebCodex 自己的命令权限,并不是 OS sandbox:Cargo/Go 构建逻辑以及项目 build script 仍可能产生自己的文件系统或网络副作用。超出 v1 contract 的构建继续显式使用 lower-level execution 工具。 diff --git a/src/runner_quic.rs b/src/runner_quic.rs index 8a8860ace..7fe4deac8 100644 --- a/src/runner_quic.rs +++ b/src/runner_quic.rs @@ -608,6 +608,7 @@ mod tests { structured_go_test_json: true, project_validation_v1: false, project_build_v1: false, + project_dependency_policy_v1: false, project_go_single_module_v1: false, project_validation_package_scope_v1: false, project_validation_test_options_v1: false, diff --git a/src/runner_ws.rs b/src/runner_ws.rs index a9463e274..b4e6dcb9c 100644 --- a/src/runner_ws.rs +++ b/src/runner_ws.rs @@ -419,6 +419,7 @@ mod tests { structured_go_test_json: true, project_validation_v1: false, project_build_v1: false, + project_dependency_policy_v1: false, project_go_single_module_v1: false, project_validation_package_scope_v1: false, project_validation_test_options_v1: false, diff --git a/src/tool_runtime/cargo.rs b/src/tool_runtime/cargo.rs index 91c79cc65..817a0840e 100644 --- a/src/tool_runtime/cargo.rs +++ b/src/tool_runtime/cargo.rs @@ -387,6 +387,7 @@ impl ToolRuntime { features, package: None, packages, + dependency_mode: None, }, )), ValidationRunRequest { @@ -521,6 +522,7 @@ impl ToolRuntime { package, packages: None, no_run, + dependency_mode: None, }, )), ValidationRunRequest { diff --git a/src/tool_runtime/cargo_tools.rs b/src/tool_runtime/cargo_tools.rs index 6dd01f101..2eadf0ce4 100644 --- a/src/tool_runtime/cargo_tools.rs +++ b/src/tool_runtime/cargo_tools.rs @@ -20,6 +20,7 @@ impl ToolRuntime { action, adapter, scope, + dependency_policy, test, timeout_secs, } => { @@ -30,6 +31,7 @@ impl ToolRuntime { action, adapter, scope, + dependency_policy, test, timeout_secs, ssh_resource, diff --git a/src/tool_runtime/dispatch/execution.rs b/src/tool_runtime/dispatch/execution.rs index 8f0fb3a86..599da071e 100644 --- a/src/tool_runtime/dispatch/execution.rs +++ b/src/tool_runtime/dispatch/execution.rs @@ -70,6 +70,7 @@ impl ToolRuntime { cwd, adapter, scope, + dependency_policy, timeout_secs, } => { self.project_build( @@ -78,6 +79,7 @@ impl ToolRuntime { cwd, adapter, scope, + dependency_policy, timeout_secs, structured_handoff_max_secs, ssh_resource.as_deref(), diff --git a/src/tool_runtime/project_build.rs b/src/tool_runtime/project_build.rs index 62252c902..a2794ac0c 100644 --- a/src/tool_runtime/project_build.rs +++ b/src/tool_runtime/project_build.rs @@ -39,6 +39,7 @@ impl ToolRuntime { cwd: Option, adapter: Option, scope: Option, + dependency_policy: Option, timeout_secs: Option, handoff_max_secs: Option, ssh_resource: Option<&str>, @@ -103,6 +104,7 @@ impl ToolRuntime { cwd, adapter: adapter.unwrap_or_default(), scope, + dependency_policy, }; if let Err(error) = request.validate() { return process_tool_failure_result( diff --git a/src/tool_runtime/tests/metadata.rs b/src/tool_runtime/tests/metadata.rs index 8d77de391..4ff271649 100644 --- a/src/tool_runtime/tests/metadata.rs +++ b/src/tool_runtime/tests/metadata.rs @@ -505,6 +505,7 @@ async fn register_agent_projects_for_auth( structured_go_test_json: true, project_validation_v1: false, project_build_v1: false, + project_dependency_policy_v1: false, project_go_single_module_v1: false, project_validation_package_scope_v1: false, project_validation_test_options_v1: false, diff --git a/src/tool_runtime/tests/project_build_handoff.rs b/src/tool_runtime/tests/project_build_handoff.rs index b166c4343..2ae6bcee8 100644 --- a/src/tool_runtime/tests/project_build_handoff.rs +++ b/src/tool_runtime/tests/project_build_handoff.rs @@ -34,6 +34,7 @@ fn call() -> ToolCall { cwd: None, adapter: Some(ProjectBuildAdapter::Rust), scope: None, + dependency_policy: None, timeout_secs: Some(60), } } diff --git a/src/tool_runtime/tests/validation_handoff/project_validation.rs b/src/tool_runtime/tests/validation_handoff/project_validation.rs index 3048cf105..2975d6b17 100644 --- a/src/tool_runtime/tests/validation_handoff/project_validation.rs +++ b/src/tool_runtime/tests/validation_handoff/project_validation.rs @@ -101,6 +101,7 @@ fn call_with_scope( action, adapter: None, scope: packages.map(|packages| ProjectValidationScope { packages }), + dependency_policy: None, test: None, timeout_secs: Some(60), } diff --git a/src/tool_runtime/validation.rs b/src/tool_runtime/validation.rs index c7281b31b..8560c2787 100644 --- a/src/tool_runtime/validation.rs +++ b/src/tool_runtime/validation.rs @@ -383,6 +383,7 @@ impl ToolRuntime { action: webcodex_core::project_validation::ProjectValidationAction, adapter_hint: Option, scope: Option, + dependency_policy: Option, test: Option, timeout_secs: Option, ssh_resource: Option<&str>, @@ -422,6 +423,7 @@ impl ToolRuntime { action, adapter: adapter_hint.unwrap_or_default(), scope, + dependency_policy, test, }; if let Err(e) = request.validate() {