diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs index 6ac3fe4dbbe..310ddb5eb76 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs @@ -3007,12 +3007,25 @@ impl CodexCommandExecutor { } // Only the authenticated controller can rebind these run-scoped launch - // settings. Executable, model, instructions, approval mode, and all other - // flags remain part of the immutable durable provider profile. + // settings: per-run filesystem and shell-environment entries, and Codex + // feature toggles (`--enable`/`--disable`). The controller recomputes the + // toggles from Paperclip policy for every launch, so a saved thread must + // follow a release that turns a feature on or off rather than become + // impossible to resume. Executable, model, instructions, approval mode, + // sandbox, permission profile, subcommand, and all other flags remain part + // of the immutable durable provider profile. fn stable_launch_args(args: &[String]) -> Vec { let mut stable = Vec::new(); let mut index = 0; while index < args.len() { + if (args[index] == "--enable" || args[index] == "--disable") && index + 1 < args.len() { + index += 2; + continue; + } + if args[index].starts_with("--enable=") || args[index].starts_with("--disable=") { + index += 1; + continue; + } if args[index] == "-c" && index + 1 < args.len() { let key = args[index + 1].split('=').next().unwrap_or(""); if matches!( @@ -6468,6 +6481,139 @@ done fs::remove_dir_all(directory).unwrap(); } + #[test] + fn saved_thread_adopts_the_controllers_current_codex_feature_policy() { + // A thread saved before a Paperclip upgrade carries that release's + // feature toggles. The controller recomputes them for every launch, so a + // changed toggle must not strand the saved thread. Sandbox, permission + // profile, and command changes stay protected. + let saved_args: Vec = vec![ + "-c", + "default_permissions=\"paperclip-runner-workspace-only\"", + "-c", + "shell_environment_policy.set={PATH=\"/run/a\"}", + "--disable", + "image_generation", + "app-server", + ] + .into_iter() + .map(str::to_owned) + .collect(); + let current_args: Vec = vec![ + "-c", + "default_permissions=\"paperclip-runner-workspace-only\"", + "-c", + "shell_environment_policy.set={PATH=\"/run/b\"}", + "app-server", + ] + .into_iter() + .map(str::to_owned) + .collect(); + let mut settled = opencode_result_state(); + settled.config.provider = "codex".to_owned(); + settled.config.driver = "codex_app_server".to_owned(); + settled.config.command = PathBuf::from("must-not-start-during-attachment"); + settled.config.model = None; + settled.config.args = saved_args.clone(); + settled.active_provider_turn_id = None; + settled.provider_process_generation = 1; + settled.lifecycle = "prepared".to_owned(); + + let directory = std::env::temp_dir().join(format!( + "paperclip-provider-feature-policy-{}", + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&directory).unwrap(); + let mut executor = CodexCommandExecutor::new(&directory); + executor.state = Some(settled.clone()); + executor + .attach_run(&json!({ + "provider": settled.config.clone(), + "runtimeLaunchArgs": current_args.clone(), + })) + .expect("a saved thread must follow the current feature policy"); + let rebound = executor.state.as_ref().unwrap(); + assert_eq!(rebound.config.args, current_args); + assert_eq!(rebound.thread_id.as_deref(), Some("thread-1")); + assert_eq!(rebound.lifecycle, "prepared"); + assert!(executor.provider.is_none()); + fs::remove_dir_all(directory).unwrap(); + + // Tightening the policy follows the controller as well. + let mut tightened = settled.clone(); + tightened.config.args = current_args.clone(); + let mut disabled = current_args.clone(); + disabled.splice(4..4, ["--disable".to_owned(), "memories".to_owned()]); + let directory = std::env::temp_dir().join(format!( + "paperclip-provider-feature-tightening-{}", + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&directory).unwrap(); + let mut executor = CodexCommandExecutor::new(&directory); + executor.state = Some(tightened.clone()); + executor + .attach_run(&json!({ + "provider": tightened.config.clone(), + "runtimeLaunchArgs": disabled.clone(), + })) + .unwrap(); + assert_eq!(executor.state.as_ref().unwrap().config.args, disabled); + fs::remove_dir_all(directory).unwrap(); + + // A release that turns a feature on is adopted too, in either flag form. + for enabled_form in [ + vec!["--enable".to_owned(), "goals".to_owned()], + vec!["--enable=goals".to_owned()], + ] { + let mut enabled = current_args.clone(); + enabled.splice(4..4, enabled_form); + let directory = std::env::temp_dir().join(format!( + "paperclip-provider-feature-enable-{}", + uuid::Uuid::new_v4() + )); + fs::create_dir_all(&directory).unwrap(); + let mut executor = CodexCommandExecutor::new(&directory); + executor.state = Some(tightened.clone()); + executor + .attach_run(&json!({ + "provider": tightened.config.clone(), + "runtimeLaunchArgs": enabled.clone(), + })) + .unwrap(); + let rebound = executor.state.as_ref().unwrap(); + assert_eq!(rebound.config.args, enabled); + assert_eq!(rebound.thread_id.as_deref(), Some("thread-1")); + fs::remove_dir_all(directory).unwrap(); + } + + let mut sandbox_bypass = current_args.clone(); + sandbox_bypass.insert(4, "--dangerously-bypass-approvals-and-sandbox".to_owned()); + let mut permission_profile = current_args.clone(); + permission_profile[1] = + "default_permissions=\"paperclip-runner-external-sandbox\"".to_owned(); + let mut subcommand = current_args.clone(); + subcommand[4] = "exec".to_owned(); + for changed in [sandbox_bypass, permission_profile, subcommand] { + let before = serde_json::to_value(&settled).unwrap(); + let mut executor = CodexCommandExecutor::new("unused-protected-launch-change"); + executor.state = Some(settled.clone()); + let error = executor + .attach_run(&json!({ + "provider": settled.config.clone(), + "runtimeLaunchArgs": changed, + })) + .expect_err("protected launch arguments must stay immutable"); + assert!( + error.to_string().contains("protected launch arguments"), + "{error}" + ); + assert_eq!( + serde_json::to_value(executor.state.as_ref().unwrap()).unwrap(), + before + ); + } + } + #[test] fn stopped_checkpoint_rebinding_retains_undelivered_facts_for_the_prior_authority() { let directory = std::env::temp_dir().join(format!( diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs index b23adf70231..9a83c92d94f 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs @@ -4468,6 +4468,120 @@ fn durable_backend_settles_pending_tools_when_recovery_finds_the_turn_ended() { fs::remove_dir_all(directory).expect("remove Codex integration-test directory"); } +fn drain_completed_turn(executor: &mut CodexCommandExecutor, label: &str) { + let mut turn_completed = false; + let deadline = event_deadline(); + while std::time::Instant::now() < deadline { + let events = poll_and_ack(executor).expect(label); + turn_completed |= events + .iter() + .any(|event| event.event_type == "turn.completed"); + if turn_completed && events.is_empty() { + return; + } + if events.is_empty() { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + } + panic!("{label}: the turn did not complete before the deadline"); +} + +#[test] +fn saved_codex_thread_attaches_after_a_release_changes_its_feature_toggles() { + // A thread saved by an earlier release keeps that release's feature + // toggles. The next run's controller sends the current policy, which no + // longer disables image generation. Attachment must adopt the current + // toggles and restart the provider on the same thread. + let directory = temporary_directory("feature-policy-attach"); + let mut config = provider_config( + &directory, + &["--durable-turn-ids", "--record-process-start"], + ); + let current_args = config.args.clone(); + config + .args + .extend(["--disable".to_owned(), "image_generation".to_owned()]); + let runner_config = durable_config(&directory); + let mut executor = CodexCommandExecutor::with_runner_config(&directory, &runner_config); + executor + .execute(&command( + "prepare", + 1, + "run.prepare", + json!({"provider": config, "authorizedTools": task_context_tool_set()}), + )) + .expect("prepare the thread with the earlier release's toggles"); + executor + .execute(&command("open", 2, "session.open", json!({}))) + .expect("open the thread"); + executor + .execute(&command( + "first-turn", + 3, + "turn.start", + json!({"text": "Work under the earlier release."}), + )) + .expect("start the first turn"); + drain_completed_turn(&mut executor, "drain the first turn"); + + let mut protected = current_args.clone(); + protected.push("--dangerously-bypass-approvals-and-sandbox".to_owned()); + let error = executor + .execute(&command( + "protected-attach", + 4, + "run.attach", + json!({ + "authorizedTools": task_context_tool_set(), + "runtimeLaunchArgs": protected, + }), + )) + .expect_err("a sandbox change stays protected"); + assert!( + error.to_string().contains("protected launch arguments"), + "unexpected attach error: {error}" + ); + + let attached = executor + .execute(&command( + "attach", + 5, + "run.attach", + json!({ + "authorizedTools": task_context_tool_set(), + "runtimeLaunchArgs": current_args, + }), + )) + .expect("the saved thread adopts the current feature toggles"); + assert!(attached + .events + .iter() + .any(|(event_type, _, _)| event_type == "run.attached")); + + executor + .execute(&command( + "second-turn", + 6, + "turn.start", + json!({"text": "Continue under the current release."}), + )) + .expect("start a turn on the same thread after attachment"); + drain_completed_turn(&mut executor, "drain the second turn"); + + let calls = fs::read_to_string(directory.join("calls.log")).expect("read the call log"); + assert_eq!( + calls + .lines() + .filter(|line| *line == "process-start") + .count(), + 2, + "the provider restarts once to apply the current toggles: {calls}" + ); + + executor.shutdown().expect("stop the provider"); + fs::remove_dir_all(directory).expect("remove Codex integration-test directory"); +} + #[test] fn durable_backend_rotates_tool_authority_for_fresh_run_attach() { let directory = temporary_directory("durable-tool-attach-rotation");