diff --git a/crates/webcodex-core/src/runner_protocol.rs b/crates/webcodex-core/src/runner_protocol.rs index 1c599fbc0..70b386f66 100644 --- a/crates/webcodex-core/src/runner_protocol.rs +++ b/crates/webcodex-core/src/runner_protocol.rs @@ -535,6 +535,16 @@ runner_capabilities! { #[serde(default, skip_serializing_if = "is_false")] pub project_build_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. + ProjectGoSingleModule => RUNNER_CAPABILITY_PROJECT_GO_SINGLE_MODULE("project_go_single_module_v1"), + v2_baseline = false { + /// Additive project_build/project_validate Go execution semantics. Missing + /// on older Runners is false and must fail closed at typed Job admission. + #[serde(default, skip_serializing_if = "is_false")] + pub project_go_single_module_v1: bool = false; + } /// The Runner understands the additive portable package scope carried by /// project validation requests. Older project_validation_v1 Runners reject /// scoped requests before dispatch rather than interpreting an unknown field. @@ -2738,6 +2748,7 @@ mod envelope_tests { structured_go_test_json: true, project_validation_v1: false, project_build_v1: false, + project_go_single_module_v1: false, project_validation_package_scope_v1: false, project_validation_test_options_v1: false, structured_go_test_tool: true, diff --git a/crates/webcodex-core/src/validation_identity.rs b/crates/webcodex-core/src/validation_identity.rs index 6c1a0806b..80469590e 100644 --- a/crates/webcodex-core/src/validation_identity.rs +++ b/crates/webcodex-core/src/validation_identity.rs @@ -242,6 +242,43 @@ pub fn structured_validation_target_identity( )) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StructuredValidationExecutionContext { + GoProjectSingleModuleV1, +} + +impl StructuredValidationExecutionContext { + const fn as_str(self) -> &'static str { + match self { + Self::GoProjectSingleModuleV1 => "go_project_single_module_v1", + } + } +} + +/// Derive a distinct durable target identity when the same structured argv +/// executes under materially different Runner-owned semantics. Existing target +/// identities remain byte-for-byte unchanged when no context is applied. +pub fn contextualize_structured_validation_target_identity( + identity: &str, + context: StructuredValidationExecutionContext, +) -> Option { + if !is_structured_validation_target_identity(identity) { + return None; + } + let context = context.as_str(); + let mut hasher = Sha256::new(); + hasher.update(b"webcodex-validation-target-context-v1\0"); + hasher.update((identity.len() as u64).to_le_bytes()); + hasher.update(identity.as_bytes()); + hasher.update((context.len() as u64).to_le_bytes()); + hasher.update(context.as_bytes()); + let digest = format!("{:x}", hasher.finalize()); + Some(format!( + "{STRUCTURED_VALIDATION_TARGET_PREFIX}{}", + &digest[..VALIDATION_IDENTITY_HEX_LEN] + )) +} + fn normalized_validation_target_cwd(value: Option<&Value>) -> Option { let Some(value) = value else { return Some(".".to_string()); @@ -384,6 +421,28 @@ mod tests { ); } + #[test] + fn execution_context_derives_a_distinct_stable_target_identity() { + let base = structured_validation_target_identity( + ToolValidationIdentityKind::GoTest, + &serde_json::json!({"cwd": ".", "packages": ["./..."]}), + ) + .unwrap(); + assert_eq!(base, "target:53578a0709b0ce549e125eb7"); + let contextual = contextualize_structured_validation_target_identity( + &base, + StructuredValidationExecutionContext::GoProjectSingleModuleV1, + ) + .unwrap(); + assert_eq!(contextual, "target:44a5798459663307bb9b3ea8"); + assert_ne!(contextual, base); + assert!(contextualize_structured_validation_target_identity( + "target:short", + StructuredValidationExecutionContext::GoProjectSingleModuleV1, + ) + .is_none()); + } + #[test] fn cargo_test_multi_package_identity_preserves_single_package_compatibility() { let kind = ToolValidationIdentityKind::CargoTest; diff --git a/crates/webcodex-runner-config/src/lib.rs b/crates/webcodex-runner-config/src/lib.rs index 7525f0f84..712b77b46 100644 --- a/crates/webcodex-runner-config/src/lib.rs +++ b/crates/webcodex-runner-config/src/lib.rs @@ -280,6 +280,9 @@ pub fn generated_runner_config_toml(opts: &RunnerInitOptions) -> Result ProjectBuildPlan { } } +fn go_build_request() -> ProjectBuildRequest { + ProjectBuildRequest { + project_id: "demo".into(), + cwd: None, + adapter: ProjectBuildAdapter::Go, + scope: None, + } +} + +fn go_build_plan() -> ProjectBuildPlan { + let request = go_build_request(); + let process = canonical_project_build_process("go", &request).unwrap(); + ProjectBuildPlan { + provenance: ProjectBuildProvenance { + request, + backend: "go".into(), + recipe_root: ".".into(), + root_digest: "3".repeat(64), + manifest_digest: "4".repeat(64), + invocation_digest: project_build_invocation_digest(&process), + }, + process, + } +} + fn registration(project_build: bool) -> RunnerRegisterRequest { let mut capabilities = crate::test_support::current_runner_capabilities(RunnerCapabilities::default()); @@ -147,3 +172,53 @@ async fn project_build_job_handoff_emits_typed_start_build() { Some("agent:build-runner:demo") ); } + +#[tokio::test] +async fn go_project_build_job_handoff_requires_single_module_capability() { + for supported in [false, true] { + let registry = RunnerRegistry::default(); + let mut registration = registration(true); + registration.capabilities.project_go_single_module_v1 = supported; + registry.register(registration).await.unwrap(); + + let plan = go_build_plan(); + let result = registry + .start_job_with_metadata_for_access( + ShellJobOpRequest { + login: false, + op: "start".into(), + client_id: Some("build-runner".into()), + cwd: Some("/tmp/demo".into()), + command: Some(String::new()), + timeout_secs: Some(60), + job_id: None, + since_stdout_line: None, + since_stderr_line: None, + tail_lines: None, + limit: None, + codex: None, + }, + "test".into(), + ShellJobStartMetadata { + project_id: Some("agent:build-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_go_single_module_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 c5d51b71e..96dc0cb4b 100644 --- a/crates/webcodex-runner-registry/src/tests/reconciliation.rs +++ b/crates/webcodex-runner-registry/src/tests/reconciliation.rs @@ -163,6 +163,60 @@ fn cargo_validation_start_metadata( } } + + +fn go_project_validation_start_metadata() -> ShellJobStartMetadata { + use webcodex_core::project_validation::{ + ProjectValidationAction, ProjectValidationAdapter, ProjectValidationProvenance, + ProjectValidationRequest, + }; + + let step = ShellJobValidationStep { + name: "check".to_string(), + program: "go".to_string(), + args: vec!["vet".to_string(), "./...".to_string()], + env: Vec::new(), + }; + ShellJobStartMetadata { + project_id: Some(RUNTIME_PROJECT_ID.to_string()), + session_id: Some(SESSION_ID.to_string()), + project_cwd: Some("/srv/demo".to_string()), + purpose: Some("validation".to_string()), + shell: Some("direct_argv".to_string()), + validation_steps: vec![step.clone()], + validation: Some(ShellJobValidationMetadata { + project_validation: Some(ProjectValidationProvenance { + request: ProjectValidationRequest { + project_id: "demo".into(), + cwd: None, + action: ProjectValidationAction::Check, + adapter: ProjectValidationAdapter::Go, + scope: None, + test: None, + }, + backend: "go".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".to_string(), + kind: "check".to_string(), + steps: vec![step], + effective_timeout_secs: 600, + sync_wait_secs: 1, + adapter: "go_vet".to_string(), + validation_target_id: Some("target:2123456789abcdef01234567".to_string()), + minimum_tests: None, + require_tests: None, + no_run: None, + }), + visibility: ShellJobVisibility::Public, + ..Default::default() + } +} + fn cargo_lib_validation_start_metadata() -> ShellJobStartMetadata { let mut metadata = cargo_validation_start_metadata(None, None, None); for step in &mut metadata.validation_steps { @@ -584,6 +638,37 @@ async fn old_count_capable_runner_accepts_cargo_validation_without_explicit_exec assert_eq!(request.kind, "start_validation_job"); } + + +#[tokio::test] +async fn go_project_validation_is_fenced_again_at_job_admission() { + for supported in [false, true] { + let registry = RunnerRegistry::default(); + let mut registration = register_request(INSTANCE_A, empty_inventory()); + registration.capabilities.project_validation_v1 = true; + registration.capabilities.project_go_single_module_v1 = supported; + registry.register(registration).await.unwrap(); + + let metadata = go_project_validation_start_metadata(); + assert!(metadata.validation.as_ref().unwrap().is_valid()); + let result = registry + .start_job_with_metadata( + start_request("validation"), + "tester".to_string(), + metadata, + ) + .await; + + if supported { + assert!(result.is_ok(), "{result:?}"); + } else { + let error = result.unwrap_err(); + assert!(error.contains("project_go_single_module_v1"), "{error}"); + assert!(registry.list_jobs(Some(10)).await.is_empty()); + } + } +} + #[tokio::test] async fn project_test_options_are_fenced_again_at_job_admission() { use webcodex_core::project_validation::*; diff --git a/crates/webcodex-runner/src/main.rs b/crates/webcodex-runner/src/main.rs index 19d200f38..562a59eb1 100644 --- a/crates/webcodex-runner/src/main.rs +++ b/crates/webcodex-runner/src/main.rs @@ -1539,6 +1539,9 @@ fn runner_register_capabilities(cfg: &RunnerConfig) -> RunnerCapabilities { capabilities.set(RunnerCapabilityId::StructuredGoTestJson, true); capabilities.set(RunnerCapabilityId::ProjectValidation, true); capabilities.set(RunnerCapabilityId::ProjectBuild, 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); // Portable package scope is additive to project_validation_v1 so mixed // Server/Runner deployments fail closed before sending the expanded request. capabilities.set(RunnerCapabilityId::ProjectValidationPackageScope, true); diff --git a/crates/webcodex-runner/src/webcodex_runner/job_manager.rs b/crates/webcodex-runner/src/webcodex_runner/job_manager.rs index b36c94d30..8452a550b 100644 --- a/crates/webcodex-runner/src/webcodex_runner/job_manager.rs +++ b/crates/webcodex-runner/src/webcodex_runner/job_manager.rs @@ -15,6 +15,7 @@ use super::shell::{ configured_explicit_shell_command, configured_prepared_shell_job_command, configured_shell_job_command, configured_validation_job_command, cwd_allowed, prepare_detached_process_launch, resolve_prepared_shell_profile, + run_process_with_profiles_and_execution_state_with_internal_env_and_start_hook, run_process_with_profiles_and_execution_state_with_start_hook, run_script_with_profiles_and_execution_state_with_start_hook, PreparedShellProfileCache, }; @@ -53,6 +54,8 @@ use crate::{ #[cfg(test)] use webcodex_core::runner_operation::{self, RunnerOperation}; +const GO_PROJECT_SINGLE_MODULE_ENV: [(&str, &str); 2] = [("GO111MODULE", "on"), ("GOWORK", "off")]; + const JOB_UPDATE_INTERVAL_MS: u64 = 250; /// Runner-owned liveness cadence for active Jobs. The cadence is independent /// of stdout/stderr and other Job traffic so one noisy Job cannot starve diff --git a/crates/webcodex-runner/src/webcodex_runner/job_manager/local_shell.rs b/crates/webcodex-runner/src/webcodex_runner/job_manager/local_shell.rs index a54456dd5..47f71d336 100644 --- a/crates/webcodex-runner/src/webcodex_runner/job_manager/local_shell.rs +++ b/crates/webcodex-runner/src/webcodex_runner/job_manager/local_shell.rs @@ -49,6 +49,12 @@ impl JobManager { ), _ => unreachable!("shell Job starter received non shell/validation operation"), }; + let project_go_single_module = validation + && context + .validation + .as_ref() + .and_then(|metadata| metadata.project_validation.as_ref()) + .is_some_and(|provenance| provenance.backend == "go"); if validation { // A project validation can wait in the local queue after its // admission fence. Recheck the retained project plan after that @@ -177,6 +183,9 @@ impl JobManager { .iter() .map(|(key, value)| (key.as_str(), value.as_str())), ); + if project_go_single_module { + command.envs(GO_PROJECT_SINGLE_MODULE_ENV); + } } // Raw Shell Jobs and every validation step have no stdin payload. // Never inherit the Runner's parent-liveness pipe: it stays open diff --git a/crates/webcodex-runner/src/webcodex_runner/job_manager/process.rs b/crates/webcodex-runner/src/webcodex_runner/job_manager/process.rs index a0e826a45..b061575c3 100644 --- a/crates/webcodex-runner/src/webcodex_runner/job_manager/process.rs +++ b/crates/webcodex-runner/src/webcodex_runner/job_manager/process.rs @@ -71,7 +71,12 @@ impl JobManager { }; let result = match &operation { RunnerJobOperation::StartBuild(request) => { - run_process_with_profiles_and_execution_state_with_start_hook( + let env_overrides = if request.provenance.backend == "go" { + &GO_PROJECT_SINGLE_MODULE_ENV[..] + } else { + &[] + }; + run_process_with_profiles_and_execution_state_with_internal_env_and_start_hook( generation, &policy, &shell, @@ -83,6 +88,7 @@ impl JobManager { None, request.timeout_secs, Some(stop_requested.as_ref()), + env_overrides, Some(&on_started), ) } 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 247b17260..61250448e 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 @@ -155,3 +155,100 @@ edition = "2021" assert!(lock_unpoison(&manager.queued).is_empty()); } } + +#[test] +fn go_project_build_overrides_ambient_gowork_at_spawn() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project"); + let registry = root.join("registry"); + let executable_temp = crate::tests::executable_tempdir(); + let bin = executable_temp.path().to_path_buf(); + std::fs::create_dir_all(®istry).unwrap(); + std::fs::write(root.join("go.mod"), "module example.test/demo\ngo 1.22\n").unwrap(); + std::fs::write( + registry.join("demo.toml"), + format!( + "id = 'demo'\nname = 'Demo'\npath = {}\nallow_patch = true\n", + serde_json::to_string(root.to_str().unwrap()).unwrap(), + ), + ) + .unwrap(); + + let helper = structured_process_helper(); + let fake_go = bin.join(format!("go{}", std::env::consts::EXE_SUFFIX)); + std::fs::copy(&helper.path, &fake_go).unwrap(); + let capture = root.join("build-gowork.txt"); + let mut shell = ShellConfig::default(); + shell.path_prepend.push(bin); + shell.env.insert( + "GOWORK".into(), + root.join("ambient.work").to_string_lossy().into_owned(), + ); + shell.env.insert("GO111MODULE".into(), "off".into()); + shell.env.insert( + "WEBCODEX_TEST_CAPTURE_GO_MODULE_ENV".into(), + capture.to_string_lossy().into_owned(), + ); + + let policy = RunnerPolicy { + allowed_roots: vec![root.clone()], + ..Default::default() + }; + let request = ProjectBuildRequest { + project_id: "demo".into(), + cwd: None, + adapter: ProjectBuildAdapter::Go, + scope: None, + }; + let (plan, cwd) = + crate::webcodex_runner::project_build::plan(&policy, ®istry, &request).unwrap(); + assert_eq!(plan.provenance.backend, "go"); + + let mut context = structured_process_context(&cwd, plan.process.args.len(), false); + context.runtime_project_id = Some("agent:structured-agent:demo".into()); + context.project_cwd = Some(".".into()); + context.purpose = Some("build".into()); + context + .structured_execution + .as_mut() + .unwrap() + .execution_source = "project_build".into(); + let operation = RunnerJobOperation::StartBuild(RunnerJobBuildOperation { + job_id: "go-single-module-build".into(), + cwd: Some(cwd.to_string_lossy().into_owned()), + process: plan.process, + provenance: plan.provenance, + timeout_secs: 10, + context, + }); + let wire = RunnerRequest::from_operation( + RunnerInvocationMetadata { + request_id: "go-single-module-build-request".into(), + client_id: "structured-agent".into(), + requested_by: "test".into(), + created_at: chrono::Utc::now().timestamp(), + }, + RunnerOperation::Job(operation), + ) + .unwrap(); + + let manager = JobManager::new(1); + let (sink, _rx) = structured_test_sink("structured-agent", "structured-instance"); + manager.enqueue( + sink, + PendingJobStart::from_wire(1, policy, shell, SshConfig::default(), registry, wire), + ); + assert!(manager.wait_for_workers(Instant::now() + Duration::from_secs(15))); + let snapshot = lock_unpoison(&manager.jobs) + .get("go-single-module-build") + .unwrap() + .snapshot + .clone(); + manager.stop_all(); + + assert_eq!(snapshot.status, "completed", "{snapshot:?}"); + assert_eq!( + std::fs::read_to_string(capture).unwrap(), + "GOWORK=off\nGO111MODULE=on\nARGV=build\t./...\n" + ); +} 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 e1f0e162f..746d1c621 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 @@ -186,3 +186,162 @@ edition = "2021" assert!(lock_unpoison(&manager.queued).is_empty()); } } + +#[test] +fn go_project_validation_overrides_ambient_gowork_but_direct_go_test_does_not() { + for project_gateway in [true, false] { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project"); + let registry = root.join("registry"); + let executable_temp = crate::tests::executable_tempdir(); + let bin = executable_temp.path().to_path_buf(); + std::fs::create_dir_all(®istry).unwrap(); + std::fs::write(root.join("go.mod"), "module example.test/demo\ngo 1.22\n").unwrap(); + std::fs::write( + registry.join("demo.toml"), + format!( + "id = 'demo'\nname = 'Demo'\npath = {}\nallow_patch = true\n", + serde_json::to_string(root.to_str().unwrap()).unwrap(), + ), + ) + .unwrap(); + + let helper = structured_process_helper(); + let fake_go = bin.join(format!("go{}", std::env::consts::EXE_SUFFIX)); + std::fs::copy(&helper.path, &fake_go).unwrap(); + let capture = root.join(if project_gateway { + "project-validate-gowork.txt" + } else { + "direct-go-test-gowork.txt" + }); + let ambient = root.join("ambient.work").to_string_lossy().into_owned(); + let mut shell = ShellConfig::default(); + shell.path_prepend.push(bin); + shell.env.insert("GOWORK".into(), ambient.clone()); + shell.env.insert("GO111MODULE".into(), "off".into()); + shell.env.insert( + "WEBCODEX_TEST_CAPTURE_GO_MODULE_ENV".into(), + capture.to_string_lossy().into_owned(), + ); + if !project_gateway { + shell + .env + .insert("WEBCODEX_TEST_GO_JSON_PASS".into(), "1".into()); + } + + let policy = RunnerPolicy { + allowed_roots: vec![root.clone()], + ..Default::default() + }; + let (step, metadata, cwd) = if project_gateway { + let request = ProjectValidationRequest { + project_id: "demo".into(), + cwd: None, + action: ProjectValidationAction::Check, + adapter: ProjectValidationAdapter::Go, + scope: None, + test: None, + }; + let (plan, cwd) = + crate::webcodex_runner::validation::project::plan(&policy, ®istry, &request) + .unwrap(); + let metadata = ShellJobValidationMetadata { + project_validation: Some(plan.provenance.clone()), + source_fence: None, + tool: "project_validate".into(), + kind: "check".into(), + steps: vec![plan.step.clone()], + effective_timeout_secs: 60, + sync_wait_secs: 10, + adapter: plan.adapter, + validation_target_id: Some(plan.validation_target_id), + minimum_tests: None, + require_tests: None, + no_run: None, + }; + (plan.step, metadata, cwd) + } else { + let step = ShellJobValidationStep { + name: "test".into(), + program: "go".into(), + args: vec!["test".into(), "-json".into(), "./...".into()], + env: Vec::new(), + }; + let metadata = ShellJobValidationMetadata { + project_validation: None, + source_fence: None, + tool: "go_test".into(), + kind: "test".into(), + steps: vec![step.clone()], + effective_timeout_secs: 60, + sync_wait_secs: 10, + adapter: "go_test".into(), + validation_target_id: Some("target:3123456789abcdef01234567".into()), + minimum_tests: None, + require_tests: None, + no_run: None, + }; + (step, metadata, root.clone()) + }; + assert!(metadata.is_valid()); + + let context = ShellJobContext { + runtime_project_id: Some("agent:validation-agent:demo".into()), + validation: Some(metadata), + workflow_session_id: None, + ssh_resource: None, + project_cwd: Some(".".into()), + cwd: Some(cwd.to_string_lossy().into_owned()), + purpose: Some("validation".into()), + shell: None, + command_preview: "go validation".into(), + validation_steps: vec![step.name.clone()], + structured_execution: None, + }; + let job_id = if project_gateway { + "go-single-module-validation" + } else { + "direct-go-test" + }; + let operation = RunnerJobOperation::StartValidation(RunnerJobValidationOperation { + job_id: job_id.into(), + cwd: Some(cwd.to_string_lossy().into_owned()), + steps: vec![step], + timeout_secs: 60, + context, + }); + let wire = RunnerRequest::from_operation( + RunnerInvocationMetadata { + request_id: format!("{job_id}-request"), + client_id: "validation-agent".into(), + requested_by: "test".into(), + created_at: chrono::Utc::now().timestamp(), + }, + RunnerOperation::Job(operation), + ) + .unwrap(); + + let manager = JobManager::new(1); + let (sink, _rx) = structured_test_sink("validation-agent", "validation-instance"); + manager.enqueue( + sink, + PendingJobStart::from_wire(1, policy, shell, SshConfig::default(), registry, wire), + ); + assert!(manager.wait_for_workers(Instant::now() + Duration::from_secs(15))); + let snapshot = lock_unpoison(&manager.jobs) + .get(job_id) + .unwrap() + .snapshot + .clone(); + manager.stop_all(); + + assert_eq!(snapshot.status, "completed", "{snapshot:?}"); + let observed = std::fs::read_to_string(capture).unwrap(); + let expected = if project_gateway { + "GOWORK=off\nGO111MODULE=on\nARGV=vet\t./...\n".to_string() + } else { + format!("GOWORK={ambient}\nGO111MODULE=off\nARGV=test\t-json\t./...\n") + }; + assert_eq!(observed, expected); + } +} diff --git a/crates/webcodex-runner/src/webcodex_runner/shell.rs b/crates/webcodex-runner/src/webcodex_runner/shell.rs index 0c4a1909c..442026289 100644 --- a/crates/webcodex-runner/src/webcodex_runner/shell.rs +++ b/crates/webcodex-runner/src/webcodex_runner/shell.rs @@ -157,6 +157,7 @@ use output::{read_bounded_pipe_tail, BoundedPipeTail}; use preparation::{run_prepare_command, stderr_tail}; pub(crate) use process::{ prepare_detached_process_launch, run_process_with_profiles_and_execution_state, + run_process_with_profiles_and_execution_state_with_internal_env_and_start_hook, run_process_with_profiles_and_execution_state_with_start_hook, }; use process_tree::{ diff --git a/crates/webcodex-runner/src/webcodex_runner/shell/process.rs b/crates/webcodex-runner/src/webcodex_runner/shell/process.rs index e49c006de..dff448913 100644 --- a/crates/webcodex-runner/src/webcodex_runner/shell/process.rs +++ b/crates/webcodex-runner/src/webcodex_runner/shell/process.rs @@ -107,6 +107,42 @@ pub(crate) fn run_process_with_profiles_and_execution_state_with_start_hook( timeout_secs: u64, stop_requested: Option<&AtomicBool>, on_started: Option<&dyn Fn()>, +) -> ShellCommandResult { + run_process_with_profiles_and_execution_state_with_internal_env_and_start_hook( + generation, + policy, + shell, + project_registry_dir, + cache, + cwd, + executable, + args, + stdin, + timeout_secs, + stop_requested, + &[], + on_started, + ) +} + +/// Apply Runner-owned semantic environment overrides after shell/profile +/// preparation and before native spawn. These values are internal invariants, +/// never caller/model-provided environment input. +#[allow(clippy::too_many_arguments)] +pub(crate) fn run_process_with_profiles_and_execution_state_with_internal_env_and_start_hook( + generation: u64, + policy: &RunnerPolicy, + shell: &ShellConfig, + project_registry_dir: &Path, + cache: &PreparedShellProfileCache, + cwd: Option<&str>, + executable: &str, + args: &[String], + stdin: Option<&str>, + timeout_secs: u64, + stop_requested: Option<&AtomicBool>, + env_overrides: &[(&str, &str)], + on_started: Option<&dyn Fn()>, ) -> ShellCommandResult { // Structured execution intentionally receives the same policy treatment // as run_shell. Absence of shell syntax is not a permission bypass. @@ -153,7 +189,7 @@ pub(crate) fn run_process_with_profiles_and_execution_state_with_start_hook( }) } }; - let cmd = match configured_process_command( + let mut cmd = match configured_process_command( shell, profile.as_deref(), executable, @@ -171,6 +207,9 @@ pub(crate) fn run_process_with_profiles_and_execution_state_with_start_hook( }) } }; + for (key, value) in env_overrides { + cmd.env(key, value); + } execute_configured_command( policy, cmd, diff --git a/crates/webcodex-runner/src/webcodex_runner/validation/project.rs b/crates/webcodex-runner/src/webcodex_runner/validation/project.rs index 58fd29b74..d32279e15 100644 --- a/crates/webcodex-runner/src/webcodex_runner/validation/project.rs +++ b/crates/webcodex-runner/src/webcodex_runner/validation/project.rs @@ -2,6 +2,9 @@ use super::*; use sha2::{Digest, Sha256}; use webcodex_core::project_validation::*; +use webcodex_core::validation_identity::{ + contextualize_structured_validation_target_identity, StructuredValidationExecutionContext, +}; use webcodex_validation::{ detect_validation_recipe, project_validation_operation, resolve_validation_recipe_with_packages, RecipeId, SemanticCheck, @@ -66,6 +69,15 @@ pub(crate) fn plan( let identity = operation .validation_target_id(Some(&resolved.recipe_root_relative)) .ok_or_else(|| unavailable("validation_scope_invalid", Some(backend.as_str())))?; + let identity = if backend == RecipeId::Go { + contextualize_structured_validation_target_identity( + &identity, + StructuredValidationExecutionContext::GoProjectSingleModuleV1, + ) + .ok_or_else(|| unavailable("validation_scope_invalid", Some(backend.as_str())))? + } else { + identity + }; debug_assert_eq!( operation .build_readonly_plan() 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 c894c1300..7bd26e653 100644 --- a/crates/webcodex-runner/src/webcodex_runner/validation/project_tests.rs +++ b/crates/webcodex-runner/src/webcodex_runner/validation/project_tests.rs @@ -101,6 +101,34 @@ fn project_validation_runner_resolves_all_production_actions() { .contains(root.to_str().unwrap())); } } +#[test] +fn go_project_validation_identity_is_single_module_domain_separated() { + for action in [ + ProjectValidationAction::Check, + ProjectValidationAction::Test, + ] { + let (_tmp, _root, registry, policy) = fixture("go.mod"); + let (plan, _) = project::plan(&policy, ®istry, &request(action)).unwrap(); + let semantic = match action { + ProjectValidationAction::Check => webcodex_validation::SemanticCheck::Check, + ProjectValidationAction::Test => webcodex_validation::SemanticCheck::Test, + ProjectValidationAction::FormatCheck => unreachable!(), + }; + let operation = + webcodex_validation::project_validation_operation("go", semantic, None).unwrap(); + let native_identity = operation + .validation_target_id(Some(&plan.provenance.recipe_root)) + .unwrap(); + let expected = webcodex_core::validation_identity::contextualize_structured_validation_target_identity( + &native_identity, + webcodex_core::validation_identity::StructuredValidationExecutionContext::GoProjectSingleModuleV1, + ) + .unwrap(); + assert_eq!(plan.validation_target_id, expected); + assert_ne!(plan.validation_target_id, native_identity); + } +} + #[test] fn project_validation_package_scope_maps_through_canonical_operations() { use ProjectValidationAction::*; @@ -166,12 +194,22 @@ fn project_validation_package_scope_maps_through_canonical_operations() { .map(|scope| scope.packages.clone()), ) .unwrap(); - assert_eq!( - plan.validation_target_id, - operation - .validation_target_id(Some(&plan.provenance.recipe_root)) - .unwrap() - ); + let native_identity = operation + .validation_target_id(Some(&plan.provenance.recipe_root)) + .unwrap(); + let expected_identity = if plan.provenance.backend == "go" { + webcodex_core::validation_identity::contextualize_structured_validation_target_identity( + &native_identity, + webcodex_core::validation_identity::StructuredValidationExecutionContext::GoProjectSingleModuleV1, + ) + .unwrap() + } else { + native_identity.clone() + }; + assert_eq!(plan.validation_target_id, expected_identity); + if plan.provenance.backend == "go" { + assert_ne!(plan.validation_target_id, native_identity); + } assert_eq!( plan.step, operation.build_readonly_plan().unwrap().structured_step diff --git a/crates/webcodex-tool-contracts/src/tool_definition/jobs.rs b/crates/webcodex-tool-contracts/src/tool_definition/jobs.rs index a63c79b44..482adda4e 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. 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. 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 a86a7640d..e5c1a5a3f 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. 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 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.", ) .with_execution(super::ToolExecutionContract::new( super::ToolExecutionForm::StructuredValidation, super::ToolExecutionLifetime::Runner, diff --git a/crates/webcodex-validation/src/evidence_tests.rs b/crates/webcodex-validation/src/evidence_tests.rs index 08f2b01cc..eeb2c76b0 100644 --- a/crates/webcodex-validation/src/evidence_tests.rs +++ b/crates/webcodex-validation/src/evidence_tests.rs @@ -1307,6 +1307,91 @@ fn same_validation_identity_success_resolves_failure_without_deleting_history() assert_eq!(validation["events_total"], 2); } +#[test] +fn ambient_go_test_success_does_not_resolve_single_module_gateway_failure() { + use webcodex_core::validation_identity::{ + contextualize_structured_validation_target_identity, StructuredValidationExecutionContext, + }; + + let store = SessionStore::default(); + let project = "agent:eval:demo"; + let session = store.start_session(Some(project.to_string()), None); + let direct_target = structured_validation_target_identity( + ToolValidationIdentityKind::GoTest, + &json!({"cwd": ".", "packages": ["./..."]}), + ) + .unwrap(); + let gateway_target = contextualize_structured_validation_target_identity( + &direct_target, + StructuredValidationExecutionContext::GoProjectSingleModuleV1, + ) + .unwrap(); + assert_ne!(gateway_target, direct_target); + + let job_id = "job_go_project_single_module_failure"; + assert!(store.record_validation_job_terminal( + &session.session_id, + job_id, + &[job_id], + "project_validate", + session_tool_contract("project_validate"), + Some(project.to_string()), + &gateway_target, + None, + "failed", + Some(1), + Some(false), + Some(100), + Some(110), + Some(10_000), + Some(json!({ + "action": "test", + "adapter": "go_test", + "backend": "go", + "validation_tool": "go_test" + })), + )); + let failed = + validation_summary_for_session(&store.summary(&session.session_id, Some(50)).unwrap()); + assert_eq!(failed["unresolved_failures"]["count"], 1); + assert_eq!(failed["latest_failure"]["identity"], gateway_target); + + record_finished_tool( + &store, + &session.session_id, + "go_test", + json!({"project": project, "cwd": ".", "packages": ["./..."]}), + true, + json!({ + "exit_code": 0, + "execution_state": "completed", + "stdout_tail": r#"{"Action":"start","Package":"example.test/demo"} +{"Action":"pass","Package":"example.test/demo","Test":"TestPass"} +{"Action":"pass","Package":"example.test/demo"} +"#, + "stderr_tail": "", + "stdout_truncated": false, + "stderr_truncated": false, + "tests_detected": true, + "tests_run_count": 1, + "tests_passed": 1, + "tests_failed": 0, + "zero_tests_run": false + }), + ); + + let validation = + validation_summary_for_session(&store.summary(&session.session_id, Some(50)).unwrap()); + assert_eq!(validation["historical_failures"]["count"], 1); + assert_eq!(validation["resolved_failures"]["count"], 0); + assert_eq!(validation["unresolved_failures"]["count"], 1); + assert_eq!( + validation["unresolved_failures"]["events"][0]["identity"], + gateway_target + ); + assert_eq!(validation["latest_success"]["identity"], direct_target); +} + #[test] fn generic_cargo_test_zero_tests_is_inconclusive_without_failing_execution() { let store = SessionStore::default(); diff --git a/docs/MCP.md b/docs/MCP.md index 800d828dd..456ef077e 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -350,11 +350,11 @@ work_on_project `present_work_result` is a one-card presentation layer for substantial coding, not a correctness primitive. Once mounted, its App-only state reads keep current progress, workspace, validation, and review visible without model polling. A non-blocking `finish_coding_task` seals eligible final changes in the presentation cache at closeout; the same card then discovers that immutable snapshot and can lazily expand per-file diffs. Tiny/read-only work should skip the card; repeated presentation of the same Session should be avoided. -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 ./...`. 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`, so mixed-version deployments fail closed before sending the expanded request. +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. 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, dependency/network policy, or artifact-discovery contract. Node/Python recipes fail closed as unsupported in v1. -`project_build` requires the additive `project_build_v1` Runner capability at both planning and typed Job admission. 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. +`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. For `action="test"`, optional `test` selects tests and states the evidence requirement: diff --git a/docs/MCP.zh-CN.md b/docs/MCP.zh-CN.md index bbda71d80..d9ca1d82a 100644 --- a/docs/MCP.zh-CN.md +++ b/docs/MCP.zh-CN.md @@ -269,11 +269,11 @@ work_on_project `present_work_result` 是 substantial coding 的一次性可视化层,不是 correctness primitive。挂载后,卡片通过 App-only state read 持续显示 Progress、Workspace、Validation 与 Review,无需模型轮询。`finish_coding_task` 在 non-blocking closeout 时把 eligible final changes seal 到 presentation cache,同一张卡随后发现这份 immutable snapshot,并按文件 lazy 展开 diff。tiny/read-only 工作应跳过这张卡,同一 Session 不应重复 presentation。 -普通的 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 ./...`。可选的有界 `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`,因此 mixed-version 部署会在 expanded request 发给旧 Runner 前 fail closed。 +普通的 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。请求不能携带 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、依赖/网络策略或 artifact discovery contract;v1 检测到 Node/Python recipe 时 fail closed。 -`project_build` 在 planning 与 typed Job admission 两处都要求 additive `project_build_v1` Runner capability。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 工具。 +`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 工具。 Adaptive Runtime 可以把常用工具直接暴露,把 long-tail 工具通过 `call_runtime_tool` 暴露。direct/gateway 只影响 model exposure,不改变 schema validation、OAuth scope、Project authority、permission policy、Runner capability、Session fence 或 tool effects。 diff --git a/src/runner_quic.rs b/src/runner_quic.rs index fafccd1b9..8a8860ace 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_go_single_module_v1: false, project_validation_package_scope_v1: false, project_validation_test_options_v1: false, structured_go_test_tool: true, diff --git a/src/runner_ws.rs b/src/runner_ws.rs index 879b885f8..a9463e274 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_go_single_module_v1: false, project_validation_package_scope_v1: false, project_validation_test_options_v1: false, structured_go_test_tool: true, diff --git a/src/tool_runtime/tests/metadata.rs b/src/tool_runtime/tests/metadata.rs index ccf015534..8d77de391 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_go_single_module_v1: false, project_validation_package_scope_v1: false, project_validation_test_options_v1: false, structured_go_test_tool: true, diff --git a/src/tool_runtime/tests/validation_handoff/project_validation.rs b/src/tool_runtime/tests/validation_handoff/project_validation.rs index e32dd2ea7..3048cf105 100644 --- a/src/tool_runtime/tests/validation_handoff/project_validation.rs +++ b/src/tool_runtime/tests/validation_handoff/project_validation.rs @@ -13,6 +13,7 @@ async fn setup(grace_ms: u64) -> ToolRuntime { structured_validation_argv: true, structured_cargo_check_packages: true, project_validation_v1: true, + project_go_single_module_v1: true, project_validation_package_scope_v1: true, project_validation_test_options_v1: true, structured_go_test_json: true, diff --git a/tests/fixtures/process_argv_helper.rs b/tests/fixtures/process_argv_helper.rs index a863e55c5..4d3efa74e 100644 --- a/tests/fixtures/process_argv_helper.rs +++ b/tests/fixtures/process_argv_helper.rs @@ -152,6 +152,26 @@ fn append_start_marker(marker: &str, nonce: &str) { } fn main() { + if let Ok(path) = std::env::var("WEBCODEX_TEST_CAPTURE_GO_MODULE_ENV") { + let gowork = std::env::var("GOWORK").unwrap_or_else(|_| "".to_string()); + let go111module = + std::env::var("GO111MODULE").unwrap_or_else(|_| "".to_string()); + let argv = std::env::args().skip(1).collect::>().join("\t"); + std::fs::write( + path, + format!("GOWORK={gowork}\nGO111MODULE={go111module}\nARGV={argv}\n"), + ) + .unwrap(); + if std::env::var("WEBCODEX_TEST_GO_JSON_PASS").as_deref() == Ok("1") { + println!(r#"{{"Action":"start","Package":"example.test/demo"}}"#); + println!( + r#"{{"Action":"pass","Package":"example.test/demo","Test":"TestPass"}}"# + ); + println!(r#"{{"Action":"pass","Package":"example.test/demo"}}"#); + } + return; + } + let mut args = std::env::args().skip(1); match args.next().as_deref() { Some("argv") => {