Skip to content

Codex mobile pairing can get stuck when local global state keeps revoked client/environment IDs #23112

Open

Description

@jackljp

Summary

Codex Desktop mobile pairing can get stuck in a stale state after a failed or revoked mobile enrollment. In my case, logging out and back in on the phone, restarting both apps, and enabling MFA did not recover the connection.

The desktop setup page stayed on "Approve on mobile device" with a QR code, while the iPhone stayed on the "Open Codex" / security setup flow and never completed pairing.

The underlying problem appears to be that Codex Desktop kept local remote-control enrollment state in ~/.codex/.codex-global-state.json even after the corresponding cloud-side client/environment records had been removed or no longer existed.

Environment

  • Codex Desktop: 26.513.31313
  • Local app server observed during debugging: 0.131.0-alpha.9
  • ChatGPT iOS app: 1.2026.125
  • Host OS: macOS
  • Phone: iPhone / iOS
  • MFA: enabled before the final retry

Symptoms

  1. Desktop shows the Codex mobile setup QR code and waits at "Approve on mobile device".
  2. iPhone ChatGPT/Codex flow remains stuck around "Open Codex" / security setup.
  3. Reinstalling/restarting the mobile app did not fix it.
  4. Logging out and back in on the mobile app did not fix it.
  5. Restarting Codex Desktop did not fix it.
  6. Enabling MFA did not fix it.

Diagnostics

Using the existing Codex Desktop auth token, I inspected the remote-control backend state:

  • GET /backend-api/wham/remote/control/clients?limit=100
  • GET /backend-api/codex/remote/control/environments?limit=100
  • GET /backend-api/wham/remote/control/mfa_requirement

At first, the account had many stale phone/tablet clients in pending_enrollment plus one enrolled desktop client. The account also had a Codex Desktop remote environment.

After deleting the pending mobile clients and the old desktop client/environment, the cloud-side state was clean:

  • clients = 0
  • environments = 0
  • mfa_requirement = required

The access token already reflected MFA (amr included MFA/OTP), so MFA itself did not appear to be the remaining blocker.

However, Codex Desktop still had stale local remote-control state in:

~/.codex/.codex-global-state.json

Specifically, the file still contained keys like:

electron-remote-control-client-enrollments
electron-local-remote-control-environment-id
electron-local-remote-control-installation-id

Those values pointed at the old client/environment IDs that no longer existed server-side.

Workaround

The workaround was to clear the local remote/mobile global-state keys after fully quitting Codex Desktop.

Important: do not edit this file while Codex is still running, because Codex may write the old in-memory state back on quit.

# 1. Quit Codex Desktop completely first, e.g. Cmd+Q.
# 2. Confirm the main app process is gone.
ps -axo pid,args | grep '/Applications/Codex.app/Contents/MacOS/Codex'

# 3. Back up and remove only the local mobile/remote pairing keys.
STATE="$HOME/.codex/.codex-global-state.json"
cp "$STATE" "$STATE.mobile-pairing-backup.$(date +%Y%m%d%H%M%S)"

node <<'NODE'
const fs = require("fs");
const path = require("path");

const file = path.join(process.env.HOME, ".codex", ".codex-global-state.json");
const state = JSON.parse(fs.readFileSync(file, "utf8"));

for (const key of [
  "electron-remote-control-client-enrollments",
  "electron-local-remote-control-environment-id",
  "electron-local-remote-control-installation-id",
  "codex-mobile-setup-completed",
]) {
  delete state[key];
}

const atomState = state["electron-persisted-atom-state"];
if (atomState && typeof atomState === "object" && !Array.isArray(atomState)) {
  for (const key of Object.keys(atomState)) {
    const lower = key.toLowerCase();
    if (
      lower.includes("remote-control") ||
      lower.includes("remote_control") ||
      lower.includes("codex-mobile")
    ) {
      delete atomState[key];
    }
  }
}

fs.writeFileSync(file, JSON.stringify(state, null, 2) + "\n");
NODE

Then reopen Codex Desktop, start the Codex mobile setup again, and scan a fresh QR code from the iPhone.

Expected behavior

Codex Desktop should detect when the locally stored remote-control enrollment references a server-side client/environment that no longer exists, then automatically discard the stale local pairing state and start a fresh enrollment.

At minimum, the mobile setup UI should provide a clear Reset mobile pairing state or Repair mobile setup action that clears:

  • stale pending mobile clients
  • stale local desktop client enrollment
  • stale local environment/installation IDs

Why this matters

The current failure mode is hard for users to recover from because all obvious steps look successful but do not actually reset the broken state:

  • mobile logout/login
  • desktop restart
  • mobile reinstall/restart
  • MFA enablement

The UI gives the impression that the user still needs to approve on the phone, but the phone and desktop are effectively waiting on incompatible/stale pairing state.

Activity

  1. added
    bugSomething isn't working
    appIssues related to the Codex desktop app
    app-serverIssues involving app server protocol or interfaces
    on May 17, 2026
  2. jackljp commented on May 17, 2026

    @jackljp
    Author

    Adding a follow-up with the part that I think makes this report useful even if the user-visible symptom overlaps with #22700, #22714, #22770, #22781, #23055, and #23078.

    I agree this is in the same bug family: revoked/stale Codex Mobile remote-control pairing prevents a fresh QR pairing. The non-duplicate/additional finding here is the exact layer where the stale state remained in my case.

    What is different in this report

    1. The server-side remote state was verified clean.

      After using backend cleanup for stale records, both of these authenticated endpoints returned empty lists:

      • GET /backend-api/wham/remote/control/clients?limit=100 -> count = 0
      • GET /backend-api/codex/remote/control/environments?limit=100 -> count = 0

      So this was not only the mobile app showing a stale backend host/environment.

    2. MFA was not the remaining blocker.

      MFA had been enabled and the refreshed local token included MFA/OTP auth method claims. The API still returned mfa_requirement = required, but the setup flow remained stuck even with MFA satisfied.

    3. The stale record was still present in Codex Desktop local global state.

      Even with cloud clients/environments empty, ~/.codex/.codex-global-state.json still contained local remote-control pairing state:

      electron-remote-control-client-enrollments
      electron-local-remote-control-environment-id
      electron-local-remote-control-installation-id
      

      Those values referenced the previous desktop client/environment, which no longer existed server-side.

    4. Clearing those Desktop global-state keys resolved the pairing issue in this case.

      The important detail is that Codex Desktop must be fully quit before clearing the keys. If the file is edited while the app is still running, the app can write the stale in-memory state back on quit.

    5. This appears distinct from the app-server SQLite enrollment recovery path.

      The open-source app-server already has recovery for stale persisted remote-control enrollments when the WebSocket returns HTTP 404:

      tungstenite::Error::Http(response) if response.status().as_u16() == 404 => {
      info!(
      "remote control websocket returned HTTP 404; clearing stale enrollment before re-enrolling: websocket_url={}, account_id={}, server_id={}, environment_id={}",
      remote_control_target.websocket_url,
      auth.account_id,
      enrollment_ref.server_id,
      enrollment_ref.environment_id
      );
      if let Err(clear_err) = update_persisted_remote_control_enrollment(
      Some(state_db),
      remote_control_target,
      &auth.account_id,
      connect_options.app_server_client_name,
      /*enrollment*/ None,
      )
      .await
      {
      warn!(
      "failed to clear stale remote control enrollment in sqlite state db: {clear_err}"
      );
      }
      *enrollment = None;
      status_publisher.publish_environment_id(/*environment_id*/ None);

      There is also a test for that path:

      async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404() {
      let listener = TcpListener::bind("127.0.0.1:0")
      .await
      .expect("listener should bind");
      let remote_control_url = remote_control_url_for_listener(&listener);
      let codex_home = TempDir::new().expect("temp dir should create");
      let state_db = remote_control_state_runtime(&codex_home).await;
      let remote_control_target =
      normalize_remote_control_url(&remote_control_url).expect("target should parse");
      let expected_server_name = gethostname().to_string_lossy().trim().to_string();
      let stale_enrollment = RemoteControlEnrollment {
      account_id: "account_id".to_string(),
      environment_id: "env_stale".to_string(),
      server_id: "srv_e_stale".to_string(),
      server_name: "stale-server".to_string(),
      };
      let refreshed_enrollment = RemoteControlEnrollment {
      account_id: "account_id".to_string(),
      environment_id: "env_refreshed".to_string(),
      server_id: "srv_e_refreshed".to_string(),
      server_name: expected_server_name,
      };
      update_persisted_remote_control_enrollment(
      Some(state_db.as_ref()),
      &remote_control_target,
      "account_id",
      /*app_server_client_name*/ None,
      Some(&stale_enrollment),
      )
      .await
      .expect("stale enrollment should save");
      let (transport_event_tx, _transport_event_rx) =
      mpsc::channel::<TransportEvent>(CHANNEL_CAPACITY);
      let shutdown_token = CancellationToken::new();
      let (remote_task, remote_handle) = start_remote_control(
      RemoteControlStartConfig {
      remote_control_url,
      installation_id: TEST_INSTALLATION_ID.to_string(),
      },
      Some(state_db.clone()),
      remote_control_auth_manager_with_home(&codex_home),
      transport_event_tx,
      shutdown_token.clone(),
      /*app_server_client_name_rx*/ None,
      /*initial_enabled*/ true,
      )
      .await
      .expect("remote control should start");
      let mut status_rx = remote_handle.status_receiver();
      let websocket_request = accept_http_request(&listener).await;
      assert_eq!(
      websocket_request.request_line,
      "GET /backend-api/wham/remote/control/server HTTP/1.1"
      );
      assert_eq!(
      websocket_request.headers.get("x-codex-server-id"),
      Some(&stale_enrollment.server_id)
      );
      expect_remote_control_status(
      &mut status_rx,
      /*expected_status*/ None,
      Some("env_stale"),
      )
      .await;
      respond_with_status(websocket_request.stream, "404 Not Found", "").await;
      expect_remote_control_status(
      &mut status_rx,
      /*expected_status*/ None,
      /*expected_environment_id*/ None,
      )
      .await;
      let enroll_request = accept_http_request(&listener).await;
      assert_eq!(
      enroll_request.request_line,
      "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1"
      );
      respond_with_json(
      enroll_request.stream,
      json!({
      "server_id": refreshed_enrollment.server_id,
      "environment_id": refreshed_enrollment.environment_id,

      In other words, the missing cleanup in this case looks more like Desktop/Electron global-state reconciliation than only app-server remote_control_enrollments cleanup.

    Suggested fix direction

    On the Desktop mobile setup path, Codex could validate the local Desktop global-state enrollment/environment against the current backend state. If the locally stored client/environment no longer exists, is revoked, or cannot be used to complete setup, Desktop should clear the local pairing state and start a fresh enrollment.

    At minimum, the UI could expose a Reset/Repair Codex Mobile pairing action that clears:

    • stale Desktop global-state remote-control client enrollment
    • stale local environment/installation IDs
    • associated device key material if needed
    • stale pending/revoked cloud client records when safe

    This would make the recovery path explicit instead of requiring users to discover and edit ~/.codex/.codex-global-state.json manually.

  3. lubluniky commented on May 18, 2026

    @lubluniky

    https://x.com/borkiss727/status/2056521742550536681?s=20

    easy fix via ssh + tailscale + ChatGPT app mobile

  4. Jarvis-Baba commented on Jun 5, 2026

    @Jarvis-Baba

    This stale-state description matches what I am seeing, but with Windows Desktop + ChatGPT iOS.

    The machine had paired successfully before. After the phone/device entry was accidentally deleted from Codex Desktop, the desktop Connections page now shows no controlling devices, while the iOS app still keeps the old Codex Desktop host record as disconnected/offline.

    After that, a fresh QR scan does not seem to create a fresh enrollment. The mobile flow still appears to reuse the old/offline host record, and attempting setup ends with: Setup failed / Couldn't finish secure setup. Try again (Chinese UI: 设置失败 / 无法完成安全设置。请重试。).

    So the broken state seems to be:

    • desktop-side pairing removed
    • mobile/account-side old host retained
    • no clear "forget/remove this computer" control on iOS
    • fresh QR scan cannot recover by creating a new clean pairing

    This would be much easier to recover from if Codex mobile exposed a remove/forget action for stale hosts, or if scanning a new QR invalidated/replaced the old host enrollment when the desktop no longer recognizes the previous phone pairing.

  5. joinnpengfei-wq commented on Jul 14, 2026

    @joinnpengfei-wq

    Adding a reproducible follow-up from a newer Desktop/iOS build. The local stale-state workaround described above was extended all the way to a genuinely new installation and environment, but QR pairing still failed at the claim/binding boundary.

    Environment

    • ChatGPT/Codex Desktop 26.707.71524 (build 5263)
    • Bundled Codex CLI/app-server 0.144.2
    • macOS 15.3 (24D2059), Apple Silicon
    • ChatGPT iOS 1.2026.132; backend-reported iOS 26.5
    • mfa_requirement = suggested (not required)

    What was reset and verified

    1. Revoked the enrolled iOS client's relationship from both existing remote environments.
    2. Fully quit Desktop and cleared the remote/mobile keys in .codex-global-state.json.
    3. Deleted all remote_control_enrollments rows in state_5.sqlite; PRAGMA integrity_check = ok.
    4. Backed up and removed ~/.codex/installation_id (an additional stable identity source not covered by the original workaround).
    5. Reopened Desktop and verified that both the installation ID and environment ID changed.
    6. Detected and terminated an orphaned sidecar from the old Desktop process. The old localhost environment then became offline; the new bogon environment remained online.
    7. Generated a fresh QR code and scanned it from the iPhone.

    Result

    • The new environment is online and appears in the iOS Codex host selector.
    • Desktop remoteControl/pairing/start succeeded at 2026-07-14T05:18:02.100Z.
    • Desktop then polled remoteControl/client/list repeatedly through 05:19:54.497Z.
    • The new environment's client endpoint remained empty: paired_client_count = 0.
    • The enrolled iOS client's last_seen_at did not advance during the attempt.
    • No claimed result was observed.
    • Mac telemetry contained only Desktop/local clients over stdio; no codex_chatgpt_ios_remote, no remote-control RPC transport, and no iOS-originated thread/list reached the host.
    • iOS showed the new host selected but stayed indefinitely on skeleton task rows.

    This rules out the old installation ID, old environment ID, SQLite enrollment, conflicting sidecar, host availability, and MFA requirement as the remaining cause. The failing boundary appears to be the iOS QR/deep-link secure-setup flow not creating the backend environment-to-client binding. Environment discovery succeeds, but secure claim does not.

    This also explains the skeleton symptom discussed in #22762, but here the failure occurs one step earlier: the new environment never gains a paired client at all.

    Questions for maintainers:

    1. Can the backend trace why the QR claim did not bind the existing enrolled iOS device key to the new environment?
    2. Is there a supported way to remove stale account-level Remote clients/environments? The current app-server API can revoke an environment-client relationship, but account-level records remain.
    3. Could iOS surface a concrete claim error instead of returning to a host that only skeleton-loads?
    4. Should the Desktop repair flow also rotate ~/.codex/installation_id when a truly fresh host identity is required?

    Raw logs, reset logs, and the screenshot are retained locally and can be provided in redacted form through an appropriate private channel.

  6. iM3SK commented on Aug 20, 2026

    @iM3SK

    Windows + Android + multi-account corroboration

    I can reproduce the same claim/binding failure boundary on Windows with ChatGPT for Android. This is not just a generic network or sign-in failure.

    Environment

    • Windows 11 x64
    • Microsoft Store Stable: OpenAI.Codex 26.814.5517.0
    • Microsoft Store Beta: OpenAI.CodexBeta 26.727.4816.0
    • ChatGPT for Android (exact mobile build was not captured)
    • Two distinct ChatGPT accounts have been used on the same Windows user / Codex installation
    • Sensitive account, machine, environment, and server identifiers are omitted

    Observed behavior

    The Android pairing/Remote Control flow does not become usable. The device can appear paired at the UI/account level, but the currently authenticated desktop environment has no bound mobile client.

    Instrumented Beta trace:

    • remoteControl/pairing/start was dispatched twice.
    • Both calls completed with errorCode=null (about 1.86 s and 1.32 s).
    • Desktop then repeatedly called remoteControl/client/list.
    • Every request completed successfully, but reconciliation remained:
      • previousConnectionCount=0
      • nextConnectionCount=0
      • autoConnectConnectionCount=0
    • No Android-originated Remote Control RPC reached the host.

    The host transport itself is healthy:

    • Remote Control WebSocket transitioned Connecting -> Connected.
    • The Windows codex.exe process had established TCP/443 connections to chatgpt.com.
    • DNS and HTTPS reachability succeeded.
    • The relevant Windows Firewall application rules were enabled Allow rules.
    • Therefore the failing boundary is after desktop enrollment/connectivity but before the mobile client is claimed/bound to the current environment.

    Multi-account state evidence

    The shared .codex/state_5.sqlite contained two enabled rows in remote_control_enrollments:

    • same installation / machine
    • same wss://chatgpt.com/backend-api/wham/remote/control/server endpoint
    • different ChatGPT account_id values
    • different server/environment identities
    • both had remote_control_enabled=1

    The current desktop auth account matched the newer enrollment. The older account enrollment remained persisted.

    The older enrollment also produced seven consecutive WebSocket failures with:

    HTTP 409 Conflict
    {"detail":"Remote app server already online"}
    

    The current enrollment later connected successfully, but the mobile client list still remained empty. This separates the historical ownership conflict from the current claim/binding failure.

    A/B observations performed

    A — Stable vs Beta

    • Pairing was user-visible broken on Stable and Beta.
    • Stable was completely removed while Beta remained installed and healthy.
    • With only Beta running, the same pairing/start succeeds -> client/list stays empty behavior remained.
    • This rules out a Stable-only package failure and rules out simultaneous Stable/Beta execution as the only current cause.
    • Stable was then cleanly reinstalled and its package/signature/status verified; the instrumented trace above remains from Beta.

    B — transport vs pairing layer

    • Transport: WebSocket connected and TCP/443 established.
    • Pairing layer: no mobile client was claimed or returned.
    • This rules out a general DNS/HTTPS/firewall outage for the observed attempt.

    The mobile-side account/session could not be inspected directly because Android debugging was not available, so I cannot distinguish from host evidence alone between (1) Android retaining the old account/environment binding and (2) the secure claim flow failing to create a new binding. Both produce the verified empty current client list.

    Suggested regression matrix

    1. Pair Windows Desktop + Android with one account and confirm a non-zero client list.
    2. Add a second account using the current ChatGPT web account switcher.
    3. Switch Desktop and Android to account B.
    4. Confirm that account A's server lease is released or isolated.
    5. Pair account B and verify that the Android client is claimed into B's environment.
    6. Switch back to A and verify deterministic restoration or an explicit re-pair prompt.
    7. Repeat with Stable and Beta installed, but only one Desktop process running.
    8. Verify that no flow leaves Remote app server already online, a UI-visible stale pairing, or client/list = 0 after a successful pairing response.

    Product/UX request

    Multi-account use on one device is already a supported ChatGPT web workflow ("Add another account"). Remote Control should remain security-isolated per account/workspace, but Desktop should handle that workflow explicitly:

    • show the active account/workspace beside the pairing QR and paired devices;
    • scope enrollments, client bindings, and server ownership to that account/workspace;
    • perform a reliable ownership handoff on account switch;
    • never show a device as paired for the current account when client/list is empty;
    • provide a supported Repair / Reset mobile pairing action;
    • explain paired to another account/workspace instead of silently waiting.

    This is not a request to allow cross-account control. It is a request for correct isolation, visible account ownership, and automatic recovery.

    Related reports that cover separate dimensions of this reproduction:

  7. meirion commented on Sep 7, 2026

    @meirion

    Check system clock times on the computer and phone. I had this error on a phone and computer which had time set manually (2 mins fast). Changing both to automatic time fixed the Codex mobile pairing error immediately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    appIssues related to the Codex desktop appapp-serverIssues involving app server protocol or interfacesbugSomething isn't workingremote

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions