diff --git a/.github/scripts/fork-upstream-drift.sh b/.github/scripts/fork-upstream-drift.sh new file mode 100755 index 000000000000..9be396887265 --- /dev/null +++ b/.github/scripts/fork-upstream-drift.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Drift check for the fork's upstream sync cadence (WI-1125). +# +# Read-only: it fetches tags and fails when the fork has fallen behind, but it +# never rebases, pushes or edits a workflow. The rebase itself stays the +# runbook at docs/fork-upstream-sync.md, because replaying the carried series +# needs a person when a patch no longer applies. +# +# Two checks: +# 1. The newest stable rust-vX.Y.Z tag on the upstream remote (alpha and dev +# tags skipped) is recorded either as the rebase base in FORK.md and the +# upstream_version stamp in .github/workflows/fork-release.yml, or as the +# separate "Upstream fixes taken through" marker in FORK.md. The marker is +# for fixes taken by cherry-pick, which does not move the rebase base, +# because published fork- tags must stay reachable. +# 2. The carried-patch table in FORK.md has at most CARRIED_PATCH_CAP rows +# (about 10, the target in FORK.md). +set -euo pipefail + +cap="${CARRIED_PATCH_CAP:-10}" +remote="${UPSTREAM_REMOTE:-upstream}" +root="$(git rev-parse --show-toplevel)" + +git -C "$root" fetch --tags "$remote" + +# Newest stable tag by version, not by tag date. `sort -V` orders the numeric +# suffix; a tag with a pre-release suffix (alpha, dev) is excluded first. +newest="$( + git -C "$root" tag -l 'rust-v*' | + grep -E '^rust-v[0-9]+(\.[0-9]+){2}$' | + sort -V | + tail -n1 +)" +if [[ -z "$newest" ]]; then + echo "no stable rust-vX.Y.Z tag found on ${remote}" >&2 + exit 1 +fi +newest_version="${newest#rust-v}" + +recorded_tag="$( + grep -oE 'rust-v[0-9]+(\.[0-9]+){2}' "$root/FORK.md" | + head -n1 +)" +taken_through="$( + sed -n 's/^[[:space:]-]*Upstream fixes taken through:[[:space:]]*//p' "$root/FORK.md" | + head -n1 +)" +stamped_version="$( + sed -n 's/^[[:space:]]*upstream_version="\([0-9][0-9.]*\)".*/\1/p' \ + "$root/.github/workflows/fork-release.yml" | + head -n1 +)" + +failed=0 +if [[ "$taken_through" == "$newest" ]]; then + echo "FORK.md: upstream fixes taken through ${taken_through} (rebase base ${recorded_tag:-})" +elif [[ "$recorded_tag" != "$newest" || "$stamped_version" != "$newest_version" ]]; then + if [[ "$recorded_tag" != "$newest" ]]; then + echo "FORK.md records base ${recorded_tag:-}; newest stable tag is ${newest}" >&2 + fi + if [[ "$stamped_version" != "$newest_version" ]]; then + echo "fork-release.yml stamps upstream_version=${stamped_version:-}; newest is ${newest_version}" >&2 + fi + if [[ -n "$taken_through" ]]; then + echo "FORK.md upstream fixes taken through ${taken_through}, not ${newest}" >&2 + fi + failed=1 +fi + +# A carried-patch row is a table row under "## Carried patches" whose first +# cell is not the header and not the separator. +count="$( + awk ' + /^## Carried patches/ { in_table = 1; next } + in_table && /^## / { exit } + in_table && /^\| — \|/ { n++ } + END { print n + 0 } + ' "$root/FORK.md" +)" +if (( count > cap )); then + echo "carried-patch count is ${count}, over the cap of ${cap}" >&2 + failed=1 +fi + +if (( failed )); then + echo "drift: rebase onto ${newest} per docs/fork-upstream-sync.md" >&2 + exit 1 +fi + +echo "in sync: ${newest}, ${count} carried patch(es), cap ${cap}" diff --git a/.github/workflows/fork-upstream-drift.yml b/.github/workflows/fork-upstream-drift.yml new file mode 100644 index 000000000000..d92787031563 --- /dev/null +++ b/.github/workflows/fork-upstream-drift.yml @@ -0,0 +1,30 @@ +# Fork-only drift check (WI-1125). Fails when a newer upstream stable tag has +# shipped than the one recorded in FORK.md and stamped in fork-release.yml, or +# when the carried-patch table grows past its cap. It reports drift; it does +# not rebase. New file with a fork-unique name; no upstream workflow is edited. +name: fork-upstream-drift + +# No schedule trigger. GitHub runs schedule only from the repository's default +# branch, which here is main, the upstream mirror. This file lives on fork/main, +# so a cron would never fire. Run it with workflow_dispatch, or from the sync +# runbook at docs/fork-upstream-sync.md. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + drift: + name: upstream tag and carried-patch cap + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.0.1 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Check upstream drift + env: + UPSTREAM_REMOTE: https://github.com/openai/codex.git + run: bash .github/scripts/fork-upstream-drift.sh diff --git a/FORK.md b/FORK.md index e701d2b4e075..ef9e553a3af2 100644 --- a/FORK.md +++ b/FORK.md @@ -14,9 +14,18 @@ core edits. committed to it, and it is never the base of a fork pull request. - `fork/main` is the fork line: an upstream stable tag plus the carried patch series. It currently sits on `c1382380d`, the commit of upstream tag - `rust-v0.162.0`. The tag lives on a release branch, not on upstream `main`: - `2351d9e1b` (upstream `main` at fork time) is 114 commits away and not - descended from the tag, so it is not a valid base. + `rust-v0.162.0`. +- Upstream fixes taken through: rust-v0.162.1 + This line is separate from the rebase base above. It records fixes taken by + cherry-pick, which does not move `fork/main`, because published `fork-` + tags must stay reachable. The two fix commits are `f715d50688` (daemon + feature compatibility checks only on explicit CLI overrides, #52648) and + `1a1335eefe` (multiline async questions keep line breaks and links, #51866). + The tag commit `092d3acd6b` is not taken: it only bumps the workspace version + to `0.162.1`, and the fork keeps its `0.162.0` stamp. Branch + `fork/wi-1125-upstream-0-162-1`. The tag lives on a release branch, not on + upstream `main`: `2351d9e1b` (upstream `main` at fork time) is 114 commits + away and not descended from the tag, so it is not a valid base. - All fork work branches off `fork/main` and every fork pull request targets `fork/main`. Never open, comment on, or push anything to openai/codex. - Vogt pins `fork/main` commit SHAs, not tags and not `main`. @@ -26,8 +35,10 @@ OpenAI repository and carry `GitOrigin-RevId` trailers; leave them intact. ## Rebase procedure -Run this for each new upstream stable tag (`rust-vX.Y.Z`), weekly or whenever -a tag ships: +Run this for each new upstream stable tag (`rust-vX.Y.Z`), whenever a tag +ships. There is no weekly schedule: GitHub runs `schedule` only from the +default branch, which is the upstream mirror, so the drift check is run on +`workflow_dispatch` and from the runbook at `docs/fork-upstream-sync.md`. 1. `git fetch upstream --tags` and check out a fresh branch from the tag. 2. Cherry-pick (or rebase) the carried patch series from `fork/main` onto it, @@ -44,6 +55,10 @@ a tag ships: `fork-`, not the bare SHA. Record the new base tag and any dropped or reworked patch in the table below. +`.github/workflows/fork-upstream-drift.yml` fails when a newer stable tag has +shipped than both the rebase base recorded here and the "Upstream fixes taken +through" marker above, or when the table below passes ten rows. The marker +passing on its own is enough: a cherry-pick does not move the base. ## Carried patches @@ -75,4 +90,7 @@ registered yet and so cannot be disabled ahead of time: after the next mirror sync of `main`, cancel its run and `gh workflow disable postmerge-ci`. `fork-release` builds x86_64 only, on the self-hosted GCP `rust-heavy` runner, whose image bakes the musl build packages (WI-1514); only its short publish job -runs GitHub-hosted. +runs GitHub-hosted. `fork-upstream-drift` is the check that the recorded base tag, or the +"Upstream fixes taken through" marker, is still the newest stable tag +(WI-1125). It has no schedule: run it on `workflow_dispatch`, or from the +sync runbook. diff --git a/codex-rs/cli/tests/daemon_startup.rs b/codex-rs/cli/tests/daemon_startup.rs index e55abb91643c..12bcba2bcf7c 100644 --- a/codex-rs/cli/tests/daemon_startup.rs +++ b/codex-rs/cli/tests/daemon_startup.rs @@ -364,6 +364,17 @@ async fn daemon_startup(command: &str) -> Result<()> { ensure!(text.contains("Cannotusethesharedbackgroundserver:Thissessionrequiresapi_key_model_discoverytobedisabled.")); } else { ensure!(text.contains("Server:Localbackgroundserver") == restart); + if restart { + let settings: serde_json::Value = serde_json::from_slice( + &fs::read(home.path().join("app-server-daemon/settings.json"))? + )?; + let flag = if disabling && !persisted { + "auth_elicitation" + } else { + "api_key_model_discovery" + }; + assert_eq!(settings["featureOverrides"], serde_json::json!({(flag): false})); + } } } else if command == "start" { ensure!(text.contains("Server:Localbackgroundserver")); diff --git a/codex-rs/tui/src/bottom_pane/async_questions/mod.rs b/codex-rs/tui/src/bottom_pane/async_questions/mod.rs index 7753a3a67783..de4232ef8f76 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/mod.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/mod.rs @@ -158,16 +158,24 @@ impl AsyncQuestions { pub(super) fn wrapped_question_lines(&self, width: u16) -> Vec { self.current_question() .map(|q| { - let source = annotate_web_urls_in_line(q.title.clone().into()); - let wrapped = wrap_ranges_trim(&q.title, usize::from(width.max(1))) - .into_iter() - .map(|range| WrappedLine { - line: (&q.title[range.clone()]).into(), - range, - prefix_bytes: 0, + // Line::from removes line endings, so wrap and annotate each logical line + // separately to keep source byte ranges aligned with the visible text. + q.title + .split('\n') + .flat_map(|text| { + let text = text.strip_suffix('\r').unwrap_or(text); + let source = annotate_web_urls_in_line(text.to_owned().into()); + let wrapped = wrap_ranges_trim(text, usize::from(width.max(1))) + .into_iter() + .map(|range| WrappedLine { + line: (&text[range.clone()]).into(), + range, + prefix_bytes: 0, + }) + .collect(); + remap_source_wrapped_line(&source, wrapped) }) - .collect(); - remap_source_wrapped_line(&source, wrapped) + .collect() }) .unwrap_or_default() } diff --git a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_multiline.snap b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_multiline.snap new file mode 100644 index 000000000000..54cdcd05c73f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_multiline.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/async_questions/state_tests.rs +expression: "rendered[0]" +--- + + 1 of 2 + + Run this command: + + ```sh + printf '診 断 ' + ``` + Then review (https://example.com/diagnostics?view=full)? + + + Type your answer + + enter submit ⌃] skip ⇧→ main prompt + ⇧← next question diff --git a/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs b/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs index 1345dd543fb7..713ac5194a66 100644 --- a/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs +++ b/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs @@ -180,6 +180,44 @@ fn wrapped_question_url_keeps_its_complete_destination() { } } +#[test] +fn multiline_question_preserves_line_breaks_and_link_destinations() { + let url = "https://example.com/diagnostics?view=full"; + let title = format!("Run this command:\n\n```sh\nprintf '診断'\n```\nThen review ({url})?\n"); + let mut rendered = Vec::new(); + for line_ending in ["\n", "\r\n"] { + let mut editor = editor(); + editor.state.pending[0].question.title = title.replace('\n', line_ending); + + for width in [20, 60] { + let height = editor.desired_height(width); + let buffer = render_editor(&editor, width, height); + let linked_text = buffer + .content + .iter() + .filter_map(|cell| { + let symbol = cell.symbol(); + symbol.contains("\x1b]8;;").then(|| { + let text = crate::terminal_hyperlinks::strip_osc8(symbol); + assert_eq!( + symbol, + crate::terminal_hyperlinks::osc8_hyperlink(url, &text) + ); + text + }) + }) + .collect::(); + assert_eq!(linked_text, url); + let text = crate::terminal_hyperlinks::strip_osc8(&buffer_text(&buffer)); + if width == 60 { + rendered.push(text); + } + } + } + assert_eq!(rendered[0], rendered[1]); + insta::assert_snapshot!("question_multiline", rendered[0]); +} + #[test] fn selected_other_renders_as_a_dim_placeholder() { let mut editor = editor(); diff --git a/codex-rs/tui/src/daemon_recovery.rs b/codex-rs/tui/src/daemon_recovery.rs index 2c9959201eb2..0dd9808bfb5f 100644 --- a/codex-rs/tui/src/daemon_recovery.rs +++ b/codex-rs/tui/src/daemon_recovery.rs @@ -31,10 +31,15 @@ pub(super) async fn check( startup: &mut StartupDraft, target: &AppServerTarget, config: &Config, + cli_kv_overrides: &[(String, toml::Value)], managed_daemon: bool, ) -> io::Result> { let issue = match startup - .run_until(daemon_startup::compatibility_warning(target, config)) + .run_until(daemon_startup::compatibility_warning( + target, + config, + cli_kv_overrides, + )) .await? { Ok(warning) => return Ok(warning), @@ -104,7 +109,11 @@ pub(super) async fn check( }) .await?; startup - .run_until(daemon_startup::compatibility_warning(target, config)) + .run_until(daemon_startup::compatibility_warning( + target, + config, + cli_kv_overrides, + )) .await? .map_err(io::Error::other) } diff --git a/codex-rs/tui/src/daemon_startup.rs b/codex-rs/tui/src/daemon_startup.rs index 6476c94c7bc6..91c38d78373e 100644 --- a/codex-rs/tui/src/daemon_startup.rs +++ b/codex-rs/tui/src/daemon_startup.rs @@ -5,6 +5,10 @@ //! Windows sessions use explicit embedded behavior before discovery or startup. use super::*; +use codex_app_server_client::TypedRequestError; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ConfigRequirementsReadResponse; +use codex_app_server_protocol::RequestId; use std::collections::BTreeMap; const SERVER_FEATURES: [Feature; 4] = [ @@ -155,6 +159,7 @@ pub(super) fn server_features(overrides: &[(String, toml::Value)]) -> BTreeMap Result, CompatibilityError> { let AppServerTarget::LocalDaemon { allow_embedded_fallback, @@ -165,15 +170,61 @@ pub(super) async fn compatibility_warning( }; let mut restart_features = None; let check = async { - // The feature-list RPC cannot report this process-scoped structured setting. - if !config.features.enabled(Feature::CodeModeHost) - && config.code_mode.disable_in_process_fallback - { - return Err("code-mode host fallback policy requires embedded mode".to_string()); - } let client = app_server_connection::connect(target) .await .map_err(|_| "could not connect to check daemon feature settings".to_string())?; + // File settings and defaults belong to the daemon until it restarts. + // Only explicit invocation flags can request a shared-service restart. + let mut requested = server_features(cli_kv_overrides); + if requested.is_empty() { + let _ = client.shutdown().await; + return Ok(()); + } + // Both legacy managed config and current requirements override CLI flags. + let layers = crate::config_update::read_effective_config_if_supported( + client.request_handle(), + config.cwd.as_path(), + ) + .await + .map_err(|_| "could not read daemon configuration".to_string())? + .and_then(|config| config.layers) + .unwrap_or_default(); + for layer in layers.iter().filter(|layer| { + layer["disabledReason"].is_null() + && matches!( + layer["name"]["type"].as_str(), + Some("legacyManagedConfigTomlFromFile" | "legacyManagedConfigTomlFromMdm") + ) + }) { + requested.retain(|name, _| layer["config"]["features"].get(name).is_none()); + } + match client + .request_handle() + .request_typed::( + ClientRequest::ConfigRequirementsRead { + request_id: RequestId::String("tui-daemon-requirements".to_string()), + params: None, + }, + ) + .await + { + Ok(response) => { + if let Some(required) = response.requirements.and_then(|r| r.feature_requirements) { + requested.retain(|name, _| !required.contains_key(name)); + } + } + Err(TypedRequestError::Server { source, .. }) + if source.code == -32601 + || source.code == -32600 + && source.message.contains("configRequirements/read") + && (source.message.contains("unknown variant") + || source.message.contains("unknown method")) => {} + Err(_) => return Err("could not check daemon configuration requirements".to_string()), + } + if requested.is_empty() { + let _ = client.shutdown().await; + return Ok(()); + } let (tx, rx) = tokio::sync::oneshot::channel(); crate::experimental_features::fetch( client.request_handle(), @@ -184,27 +235,17 @@ pub(super) async fn compatibility_warning( let result = rx.await; let _ = client.shutdown().await; let features = result.map_err(|_| "daemon feature check was interrupted".to_string())??; - // A previous client may have launched this daemon with overrides, even if - // this client has none. Check effective values, including defaults. - for feature in SERVER_FEATURES { - let name = feature.key(); - let enabled = config.features.enabled(feature); + for (name, enabled) in &requested { if features .iter() - .find(|feature| feature.name == name) + .find(|feature| feature.name == *name) .is_some_and(|feature| feature.enabled) - != enabled + != *enabled { - restart_features = Some( - SERVER_FEATURES - .into_iter() - .map(|feature| { - (feature.key().to_string(), config.features.enabled(feature)) - }) - .collect(), - ); - let state = if enabled { "enabled" } else { "disabled" }; - return Err(format!("This session requires {name} to be {state}")); + let state = if *enabled { "enabled" } else { "disabled" }; + let reason = format!("This session requires {name} to be {state}"); + restart_features = Some(requested); + return Err(reason); } } Ok::<(), String>(()) diff --git a/codex-rs/tui/src/daemon_startup_tests.rs b/codex-rs/tui/src/daemon_startup_tests.rs index 93f169f037a9..2a6a2eaf1f5d 100644 --- a/codex-rs/tui/src/daemon_startup_tests.rs +++ b/codex-rs/tui/src/daemon_startup_tests.rs @@ -152,6 +152,10 @@ async fn daemon_feature_compatibility_respects_required_and_optional_attachment( "matching", "stale overrides", "conflicting client", + "explicit enable", + "managed", + "legacy managed", + "old requirements server", "unsupported RPC", ] { let home = TempDir::new()?; @@ -161,19 +165,21 @@ async fn daemon_feature_compatibility_respects_required_and_optional_attachment( .build() .await?; config.features.enable(Feature::ApiKeyModelDiscovery)?; - if scenario == "conflicting client" { - config.features.disable(Feature::ApiKeyModelDiscovery)?; - } + let cli_overrides = match scenario { + "matching" | "stale overrides" => Vec::new(), + _ => vec![( + "features.api_key_model_discovery".to_string(), + toml::Value::Boolean(scenario == "explicit enable"), + )], + }; let features = [ Feature::ApiKeyModelDiscovery, Feature::CodeModeHost, Feature::AuthElicitation, Feature::McpOAuthRefreshCoordination, ].map(|feature| { - let enabled = if feature == Feature::ApiKeyModelDiscovery && scenario == "stale overrides" { + let enabled = if feature == Feature::ApiKeyModelDiscovery && matches!(scenario, "stale overrides" | "explicit enable") { false - } else if feature == Feature::ApiKeyModelDiscovery && scenario == "conflicting client" { - true } else { config.features.enabled(feature) }; @@ -195,16 +201,34 @@ async fn daemon_feature_compatibility_respects_required_and_optional_attachment( else { continue; }; - let response = if request.method == "initialize" { - json!({"id": request.id, "result": {"userAgent": "daemon-test"}}) - } else { - assert_eq!(request.method, "experimentalFeature/list"); - assert_eq!(request.params.unwrap()["threadId"], json!(null)); - if scenario == "unsupported RPC" { + let response = match request.method.as_str() { + "initialize" => { + json!({"id": request.id, "result": {"userAgent": "daemon-test"}}) + } + "config/read" => { + json!({"id": request.id, "result": {"config": {}, "layers": [{ + "name": {"type": "legacyManagedConfigTomlFromFile"}, "config": + if scenario == "legacy managed" { + json!({"features": {"api_key_model_discovery": true}}) + } else { json!({}) } + }]}}) + } + "configRequirements/read" if scenario == "old requirements server" => { + json!({"id": request.id, "error": {"code": -32600, "message": "Invalid request: unknown variant `configRequirements/read`"}}) + } + "configRequirements/read" => json!({"id": request.id, "result": { + "requirements": if scenario == "managed" { + json!({"featureRequirements": {"api_key_model_discovery": true}}) + } else { json!(null) } + }}), + "experimentalFeature/list" if scenario == "unsupported RPC" => { json!({"id": request.id, "error": {"code": -32601, "message": "method not found"}}) - } else { + } + "experimentalFeature/list" => { + assert_eq!(request.params.unwrap()["threadId"], json!(null)); json!({"id": request.id, "result": {"data": features, "nextCursor": null}}) } + _ => panic!("unexpected request: {}", request.method), }; socket .send(Message::Text(response.to_string().into())) @@ -212,15 +236,19 @@ async fn daemon_feature_compatibility_respects_required_and_optional_attachment( .unwrap(); } }); - let result = daemon_startup::compatibility_warning(&target, &config).await; + let result = + daemon_startup::compatibility_warning(&target, &config, &cli_overrides).await; server.await?; - if scenario == "matching" { + if matches!( + scenario, + "matching" | "stale overrides" | "managed" | "legacy managed" + ) { assert_eq!(result?, None); continue; } let reason = match scenario { - "stale overrides" => "This session requires api_key_model_discovery to be enabled", - "conflicting client" => { + "explicit enable" => "This session requires api_key_model_discovery to be enabled", + "conflicting client" | "old requirements server" => { "This session requires api_key_model_discovery to be disabled" } "unsupported RPC" => "Experimental feature request failed", @@ -242,7 +270,7 @@ async fn daemon_feature_compatibility_respects_required_and_optional_attachment( daemon_startup::FAILURE_HINT ) ); - if scenario == "stale overrides" { + if scenario == "explicit enable" { insta::assert_snapshot!("daemon_feature_mismatch_error", error); } } diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs index 87c1c42f54d9..e09f18a29aee 100644 --- a/codex-rs/tui/src/startup_orchestration.rs +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -585,6 +585,7 @@ pub(super) async fn run_main_inner( &mut startup_draft, &app_server_target, &config, + &cli_kv_overrides, managed_daemon, ) .await? diff --git a/codex-rs/tui/tests/suite/daemon_compatibility.rs b/codex-rs/tui/tests/suite/daemon_compatibility.rs index 4ca27c838cac..9a0ca7297492 100644 --- a/codex-rs/tui/tests/suite/daemon_compatibility.rs +++ b/codex-rs/tui/tests/suite/daemon_compatibility.rs @@ -8,27 +8,16 @@ use anyhow::ensure; use codex_app_server_protocol::JSONRPCMessage; use futures::SinkExt; use futures::StreamExt; -use pretty_assertions::assert_eq; use serde_json::json; use tokio::net::UnixListener; use tokio_tungstenite::tungstenite::Message; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn incompatible_daemon_falls_back_for_default_and_explicit_features() -> Result<()> { - for scenario in ["default", "explicit", "host policy"] { +async fn incompatible_daemon_falls_back_for_explicit_features() -> Result<()> { + for scenario in ["explicit enable", "explicit disable"] { let cwd = codex_utils_cargo_bin::repo_root()?; let home = tempfile::tempdir_in("/tmp")?; write_test_config(home.path(), &cwd)?; - if scenario == "host policy" { - let path = home.path().join("config.toml"); - let contents = std::fs::read_to_string(&path)?; - std::fs::write( - path, - format!( - "features.code_mode_host = {{enabled=false, disable_in_process_fallback=true}}\n{contents}" - ), - )?; - } let socket = codex_app_server_client::app_server_control_socket_path(home.path())?; std::fs::create_dir_all(socket.parent().unwrap())?; let listener = UnixListener::bind(socket.as_path())?; @@ -43,15 +32,22 @@ async fn incompatible_daemon_falls_back_for_default_and_explicit_features() -> R let JSONRPCMessage::Request(request) = serde_json::from_str(&text)? else { continue; }; - let response = if request.method == "initialize" { - json!({"id": request.id, "result": {"userAgent": "daemon-test/0.0.0"}}) - } else { - assert_eq!(request.method, "experimentalFeature/list"); - json!({"id": request.id, "result": {"data": [{ + let response = match request.method.as_str() { + "initialize" => { + json!({"id": request.id, "result": {"userAgent": "daemon-test/0.0.0"}}) + } + "config/read" => { + json!({"id": request.id, "result": {"config": {}, "layers": []}}) + } + "configRequirements/read" => { + json!({"id": request.id, "result": {"requirements": null}}) + } + "experimentalFeature/list" => json!({"id": request.id, "result": {"data": [{ "name": "api_key_model_discovery", "stage": "stable", "displayName": null, "description": null, "announcement": null, - "enabled": scenario == "explicit", "defaultEnabled": true, - }], "nextCursor": null}}) + "enabled": scenario == "explicit disable", "defaultEnabled": true, + }], "nextCursor": null}}), + _ => panic!("unexpected request: {}", request.method), }; socket .send(Message::Text(response.to_string().into())) @@ -59,25 +55,24 @@ async fn incompatible_daemon_falls_back_for_default_and_explicit_features() -> R } Ok::<_, anyhow::Error>(()) }); - let args = if scenario == "explicit" { + let args = if scenario == "explicit disable" { vec!["-c", "features.api_key_model_discovery=false"] } else { - vec![] + vec!["-c", "features.api_key_model_discovery=true"] }; let mut terminal = PtyCodex::start(&cwd, home, &args)?; terminal.wait_for_startup()?; terminal.wait_for_screen("warning")?; terminal.write_input(b"\x14")?; let (snapshot, warning_end) = match scenario { - "default" => ( + "explicit enable" => ( "daemon_feature_mismatch", "api_key_model_discovery to be enabled.", ), - "explicit" => ( + "explicit disable" => ( "daemon_override_mismatch", "api_key_model_discovery to be disabled.", ), - "host policy" => ("daemon_host_policy_mismatch", "requires embedded mode."), _ => unreachable!(), }; terminal.wait_for_screen(warning_end)?; @@ -94,10 +89,6 @@ async fn incompatible_daemon_falls_back_for_default_and_explicit_features() -> R terminal.write_input(b"\r")?; terminal.wait_for_screen("Model:")?; ensure!(!terminal.screen_contains("unix://")); - if scenario == "host policy" { - server.abort(); - continue; - } tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 5), server).await???; } Ok(()) diff --git a/docs/fork-upstream-sync.md b/docs/fork-upstream-sync.md index d1bef81b94a7..8e42ca4c0d02 100644 --- a/docs/fork-upstream-sync.md +++ b/docs/fork-upstream-sync.md @@ -4,8 +4,10 @@ How `fork/main` is rebased onto each new upstream stable tag. The branch model and the carried-patch table live in `FORK.md`; this is the runbook. Upstream shipped 40 stable tags (`rust-v0.144` to `rust-v0.162`) in three -months and about 1,559 commits in 30 days, so this runs weekly or on every -stable tag, whichever comes first. +months and about 1,559 commits in 30 days, so this runs on every stable tag. +There is no weekly schedule: GitHub runs `schedule` only from the repository's +default branch, and that branch is `main`, the upstream mirror. The drift +check below is run on `workflow_dispatch` and by following this runbook. ## Steps @@ -46,3 +48,23 @@ does not move a tag, so older releases keep working after every rebase. happened with `mcp-server`, `--full-auto`, `wire_api=chat`, and ghost snapshots. A removal means an engine change, not a fork patch that reintroduces it. + +## Drift check + +`.github/scripts/fork-upstream-drift.sh` is the check, and +`.github/workflows/fork-upstream-drift.yml` runs it on `workflow_dispatch`. +It has no `schedule` trigger, because that would only fire from `main`. It +fetches upstream's tags and fails when the newest stable `rust-vX.Y.Z` (alpha +and dev tags excluded) is newer than both the rebase base recorded in +`FORK.md` (and the `upstream_version` stamp in +`.github/workflows/fork-release.yml`) and the separate `Upstream fixes taken +through:` marker in `FORK.md`. The marker is how a cherry-pick is recorded +without moving the rebase base. It also fails when the carried-patch table +grows past ten rows. It only reports; the rebase stays the steps above. + +Last run, 2026-10-15: `FORK.md` and `fork-release.yml` still record the rebase +base `rust-v0.162.0`. The newest stable tag on upstream is `rust-v0.162.1`, +and its three commits are being taken by cherry-pick (a separate WI-1125 pull +request), not by rebasing `fork/main`, because published `fork-` tags +must stay reachable. `FORK.md` records that as `Upstream fixes taken through: +rust-v0.162.1`.