From 4bc87705caadb7f22182e2889feda311576f3082 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:59:14 +0000 Subject: [PATCH 1/5] fork: check upstream drift weekly (WI-1125) A scheduled workflow fails when a newer stable rust-vX.Y.Z tag has shipped than the one recorded in FORK.md and stamped in fork-release.yml, or when the carried-patch table grows past ten rows. It reports; the rebase stays the runbook. The first run finds drift: the fork records rust-v0.162.0 and the newest stable tag is rust-v0.162.1. --- .github/scripts/fork-upstream-drift.sh | 77 +++++++++++++++++++++++ .github/workflows/fork-upstream-drift.yml | 29 +++++++++ FORK.md | 5 +- docs/fork-upstream-sync.md | 14 +++++ 4 files changed, 124 insertions(+), 1 deletion(-) create mode 100755 .github/scripts/fork-upstream-drift.sh create mode 100644 .github/workflows/fork-upstream-drift.yml diff --git a/.github/scripts/fork-upstream-drift.sh b/.github/scripts/fork-upstream-drift.sh new file mode 100755 index 000000000000..a5911984b747 --- /dev/null +++ b/.github/scripts/fork-upstream-drift.sh @@ -0,0 +1,77 @@ +#!/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 upstream stable tag recorded in FORK.md and the upstream_version +# stamp in .github/workflows/fork-release.yml match the newest rust-vX.Y.Z +# tag on the upstream remote. alpha and dev tags are skipped. +# 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 +)" +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 [[ "$recorded_tag" != "$newest" ]]; then + echo "FORK.md records base ${recorded_tag:-}; newest stable tag is ${newest}" >&2 + failed=1 +fi +if [[ "$stamped_version" != "$newest_version" ]]; then + echo "fork-release.yml stamps upstream_version=${stamped_version:-}; newest is ${newest_version}" >&2 + 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..80a18136f85f --- /dev/null +++ b/.github/workflows/fork-upstream-drift.yml @@ -0,0 +1,29 @@ +# 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 + +on: + schedule: + # Weekly, Monday 06:00 UTC. A new stable tag between runs is caught here. + - cron: "0 6 * * 1" + 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..c609d8d5fe9b 100644 --- a/FORK.md +++ b/FORK.md @@ -44,6 +44,8 @@ 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 the one recorded here, or when the table below passes ten rows. ## Carried patches @@ -75,4 +77,5 @@ 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 weekly check that the recorded +base tag is still the newest stable tag (WI-1125). diff --git a/docs/fork-upstream-sync.md b/docs/fork-upstream-sync.md index d1bef81b94a7..4fe042dace53 100644 --- a/docs/fork-upstream-sync.md +++ b/docs/fork-upstream-sync.md @@ -46,3 +46,17 @@ 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 weekly and on +`workflow_dispatch`. It fetches upstream's tags and fails when the newest +stable `rust-vX.Y.Z` (alpha and dev tags excluded) is newer than the tag +recorded in `FORK.md` or the `upstream_version` stamp in +`.github/workflows/fork-release.yml`, and when the carried-patch table grows +past ten rows. It only reports; the rebase stays the steps above. + +Last run, 2026-10-15: drift. `FORK.md` and `fork-release.yml` record +`rust-v0.162.0`; the newest stable tag on upstream is `rust-v0.162.1`. The +rebase onto `rust-v0.162.1` is the next sync. From cd073d28ca0173f19e4d70eb451be609ef5d5c41 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:15:01 +0000 Subject: [PATCH 2/5] fork: drop the drift schedule and accept cherry-picked fixes (WI-1125) GitHub runs schedule only from the default branch, which is the upstream mirror, so the cron could never fire. The check now passes when FORK.md's "Upstream fixes taken through" marker equals the newest stable tag, kept separate from the rebase base. --- .github/scripts/fork-upstream-drift.sh | 31 +++++++++++++++------- .github/workflows/fork-upstream-drift.yml | 7 ++--- FORK.md | 24 ++++++++++++----- docs/fork-upstream-sync.md | 32 ++++++++++++++--------- 4 files changed, 64 insertions(+), 30 deletions(-) diff --git a/.github/scripts/fork-upstream-drift.sh b/.github/scripts/fork-upstream-drift.sh index a5911984b747..9be396887265 100755 --- a/.github/scripts/fork-upstream-drift.sh +++ b/.github/scripts/fork-upstream-drift.sh @@ -7,9 +7,12 @@ # needs a person when a patch no longer applies. # # Two checks: -# 1. The upstream stable tag recorded in FORK.md and the upstream_version -# stamp in .github/workflows/fork-release.yml match the newest rust-vX.Y.Z -# tag on the upstream remote. alpha and dev tags are skipped. +# 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 @@ -38,6 +41,10 @@ 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" | @@ -45,12 +52,18 @@ stamped_version="$( )" failed=0 -if [[ "$recorded_tag" != "$newest" ]]; then - echo "FORK.md records base ${recorded_tag:-}; newest stable tag is ${newest}" >&2 - failed=1 -fi -if [[ "$stamped_version" != "$newest_version" ]]; then - echo "fork-release.yml stamps upstream_version=${stamped_version:-}; newest is ${newest_version}" >&2 +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 diff --git a/.github/workflows/fork-upstream-drift.yml b/.github/workflows/fork-upstream-drift.yml index 80a18136f85f..d92787031563 100644 --- a/.github/workflows/fork-upstream-drift.yml +++ b/.github/workflows/fork-upstream-drift.yml @@ -4,10 +4,11 @@ # 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: - schedule: - # Weekly, Monday 06:00 UTC. A new stable tag between runs is caught here. - - cron: "0 6 * * 1" workflow_dispatch: permissions: diff --git a/FORK.md b/FORK.md index c609d8d5fe9b..8e300b3d71f0 100644 --- a/FORK.md +++ b/FORK.md @@ -14,7 +14,13 @@ 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`: + `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. `rust-v0.162.1`'s three commits are being taken + that way, in a separate WI-1125 pull request. 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 @@ -26,8 +32,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, @@ -45,7 +53,9 @@ a tag ships: 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 the one recorded here, or when the table below passes ten rows. +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 @@ -77,5 +87,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. `fork-upstream-drift` is the weekly check that the recorded -base tag is still the newest stable tag (WI-1125). +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/docs/fork-upstream-sync.md b/docs/fork-upstream-sync.md index 4fe042dace53..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 @@ -50,13 +52,19 @@ does not move a tag, so older releases keep working after every rebase. ## Drift check `.github/scripts/fork-upstream-drift.sh` is the check, and -`.github/workflows/fork-upstream-drift.yml` runs it weekly and on -`workflow_dispatch`. It fetches upstream's tags and fails when the newest -stable `rust-vX.Y.Z` (alpha and dev tags excluded) is newer than the tag -recorded in `FORK.md` or the `upstream_version` stamp in -`.github/workflows/fork-release.yml`, and when the carried-patch table grows -past ten rows. It only reports; the rebase stays the steps above. - -Last run, 2026-10-15: drift. `FORK.md` and `fork-release.yml` record -`rust-v0.162.0`; the newest stable tag on upstream is `rust-v0.162.1`. The -rebase onto `rust-v0.162.1` is the next sync. +`.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`. From a19cabb2babc800779960894a281abc089306838 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 9 Oct 2026 17:05:23 +0000 Subject: [PATCH 3/5] Limit daemon feature compatibility checks to explicit CLI overrides (#52648) ## Why A running shared background server owns its feature settings until it restarts. Differences from a client's configuration files or defaults should not trigger a compatibility warning or a restart request. ## What changed - Check shared-service features only when explicitly overridden on the command line. - Exclude overrides superseded by active legacy managed configuration or feature requirements, while supporting older servers without `configRequirements/read`. - Pass only the remaining explicit feature overrides to a requested daemon restart. ## Testing Update compatibility tests for stale daemon overrides, explicit enable/disable requests, managed configuration, and older servers. Extend CLI startup tests to verify the feature overrides persisted after a restart. GitOrigin-RevId: ac92c9c02cd70fa233203ebef1182fd119325dd1 (cherry picked from commit d3f9b22b745602cb6b46e436e0ed8302a40c1fdf) --- codex-rs/cli/tests/daemon_startup.rs | 11 +++ codex-rs/tui/src/daemon_recovery.rs | 13 ++- codex-rs/tui/src/daemon_startup.rs | 87 ++++++++++++++----- codex-rs/tui/src/daemon_startup_tests.rs | 64 ++++++++++---- codex-rs/tui/src/startup_orchestration.rs | 1 + .../tui/tests/suite/daemon_compatibility.rs | 49 +++++------ 6 files changed, 153 insertions(+), 72 deletions(-) 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/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(()) From 88286d41c33d7e22b9883be3f3640e4c1f11c958 Mon Sep 17 00:00:00 2001 From: pash-openai Date: Wed, 7 Oct 2026 22:38:34 +0000 Subject: [PATCH 4/5] Preserve line breaks and links in multiline async questions (#51866) ## Why Rendering an entire question title as one terminal line removes line endings and misaligns hyperlink source ranges with the visible text. ## What changed Wrap and annotate each logical line of asynchronous question titles separately, preserving blank lines and handling both LF and CRLF endings while retaining complete hyperlink destinations. ## Testing Add a rendering regression test and snapshot covering multiline titles with code fences, Unicode text, and a URL. Check LF and CRLF rendering equivalence and hyperlink destinations at narrow and wide widths. GitOrigin-RevId: ccd7256dbaed91ce98374e52b17d3a1b87645574 (cherry picked from commit eef47199272f4eb456f45d7b795109f8c3029c76) --- .../src/bottom_pane/async_questions/mod.rs | 26 ++++++++----- ..._questions__tests__question_multiline.snap | 19 ++++++++++ .../async_questions/state_tests.rs | 38 +++++++++++++++++++ 3 files changed, 74 insertions(+), 9 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_multiline.snap 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(); From 35f3bb94de23be145307836d9aec338f29d08bda Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Fri, 9 Oct 2026 10:39:05 -0700 Subject: [PATCH 5/5] - Fixed a TUI crash when asynchronous questions contain multiple lines, preserving line breaks and complete hyperlink destinations. (#51866) - Fixed startup failures caused by differences between a running background server's feature settings and CLI defaults. Compatibility checks now apply only to explicit command-line feature overrides. (#52648) Full Changelog: https://github.com/openai/codex/compare/rust-v0.162.0...rust-v0.162.1 --- FORK.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/FORK.md b/FORK.md index 8e300b3d71f0..ef9e553a3af2 100644 --- a/FORK.md +++ b/FORK.md @@ -18,11 +18,14 @@ core edits. - 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. `rust-v0.162.1`'s three commits are being taken - that way, in a separate WI-1125 pull request. 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. + 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`.