Codex mobile does not show SSH remote projects from connected Mac host

Open 💬 10 comments Opened May 19, 2026 by jameBoy
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

Summary

Codex mobile can connect to a macOS Codex App host, and that macOS host can connect to an SSH remote project successfully. However, SSH remote projects that are visible and usable in the Mac Codex App do not appear in the ChatGPT mobile Codex project selector for the same connected Mac host.

This seems inconsistent with the Remote connections docs, which describe the remote development workflow as: the Codex App host connects to the SSH/devbox environment first, while the phone still connects to the Codex App host.

Docs reference: https://developers.openai.com/codex/remote-connections

Environment

  • Codex App for macOS: 26.513.31313
  • Codex App bundled CLI: codex-cli 0.131.0-alpha.9
  • Local platform: Darwin 24.2.0 arm64
  • Remote SSH host: Linux arm64
  • Remote Codex CLI: codex-cli 0.130.0
  • ChatGPT mobile app: iOS, latest App Store build available at time of testing
  • Same ChatGPT account and same workspace are used on both Mac and iPhone

What Works

  1. iPhone can see and connect to the Mac Codex App host.
  2. Mac Codex App can see the SSH remote project in the sidebar.
  3. Mac Codex App can connect to the SSH host.
  4. Remote host has Codex installed and available in the remote login shell.
  5. A Codex SSH app-server/proxy process is running from the Mac host to the SSH host.

Remote verification:

ssh <ssh-alias> 'pwd; hostname; whoami; command -v codex; codex --version'
/home/<remote-user>
<remote-hostname>
<remote-user>
/usr/local/bin/codex
codex-cli 0.130.0

Local Codex global state contains the remote project:

{
  "remote-projects": [
    {
      "id": "<project-id>",
      "hostId": "remote-ssh-discovered:<ssh-alias>",
      "remotePath": "/workspace",
      "label": "oci"
    }
  ],
  "project-order": [
    "<local-project-path>",
    "<project-id>"
  ]
}

Mobile remote-control enrollment exists for the Mac host:

remote_control_enrollments count: 1
server_name: <mac-hostname>.local
environment_id: env_e_<redacted>

Actual Behavior

On iPhone:

  1. Open ChatGPT mobile app.
  2. Open Codex.
  3. Select the connected Mac Codex App host.
  4. Open the project selector.

Only the local Mac project appears. The SSH remote project does not appear, even though it appears in the Mac Codex App sidebar and local Codex state.

Rescanning the Codex mobile QR code does not fix the issue. After scanning, ChatGPT mobile goes directly to the Connections page and treats the Mac host as already connected rather than refreshing the host project list.

Killing/reopening the iOS ChatGPT app does not fix the issue.

Deleting and reinstalling the iOS ChatGPT app also does not fix the issue. After reinstalling, the phone can still connect to the Mac Codex App host, but the SSH remote project still does not appear in the project selector.

Expected Behavior

After a Mac Codex App host adds an SSH remote project and the phone is connected to that Mac host, the SSH remote project should appear in the mobile Codex project selector for that host.

The user should not need to delete/reinstall the ChatGPT mobile app or redo mobile setup every time a new SSH remote project is added to the Mac host.

Steps to Reproduce

  1. On macOS, sign in to Codex App.
  2. Set up Codex mobile and connect an iPhone running ChatGPT mobile.
  3. Confirm the Mac host appears in Codex mobile.
  4. On macOS, add an SSH host in ~/.ssh/config.
  5. Confirm ssh <ssh-alias> works from the Mac.
  6. Install/authenticate Codex on the SSH host and confirm codex --version works in the remote login shell.
  7. In Mac Codex App, add/enable the SSH host under Settings > Connections.
  8. Add a remote project folder such as /workspace.
  9. Confirm the project appears in the Mac Codex App sidebar.
  10. On iPhone, select the same Mac host and open the project selector.

Observed: the SSH remote project does not appear.

Troubleshooting Already Attempted

  • Confirmed same ChatGPT account and workspace on Mac and phone.
  • Confirmed Mac host is online and awake.
  • Confirmed remote-control/mobile enrollment exists for the Mac host.
  • Confirmed remote SSH works from the Mac.
  • Confirmed remote Codex CLI exists and works.
  • Confirmed a Codex SSH app-server/proxy process is running for the remote host.
  • Reset local mobile remote-control enrollment on the Mac and rescanned the QR code.
  • Killed and reopened the iOS ChatGPT app.
  • Deleted and reinstalled the iOS ChatGPT app.

Additional Observation

Direct SSH from the iPhone to the same remote host can be made to work as a workaround. That suggests the remote host itself and remote Codex runtime are valid. The missing behavior appears specific to the mobile-to-Mac-host path not surfacing the Mac host's remote-ssh-discovered:* projects in the mobile project selector.

Impact

The documented mobile-to-Mac-to-SSH workflow is not usable from mobile when SSH remote projects added on the Mac do not sync to the mobile project selector.

View original on GitHub ↗

10 Comments

github-actions[bot] contributor · 2 months ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #23062
  • #23466

Powered by Codex Action

jshaofa-ui · 2 months ago

🔧 Proposed Fix: Forward remote-projects Through App-Server Protocol

Root Cause

The remote-control protocol forwards local projects from the Mac host's project-order config, but does NOT forward SSH remote projects from the Mac's remote-projects config. The app-server proxy process knows about these projects (it creates the SSH connections) but doesn't include them in the project list sent to mobile clients.

Fix

1. Forward remote-projects in app-server project discovery:

async function getProjectsForRemoteClient(): Promise<Project[]> {
  const globalState = await loadGlobalState();
  const projects: Project[] = [];
  
  // Local projects (already forwarded)
  for (const path of globalState['project-order'] ?? []) {
    if (await directoryExists(path)) {
      projects.push({ type: 'local', path, label: path.basename(path) });
    }
  }
  
  // SSH remote projects (NEW — currently missing)
  for (const rp of globalState['remote-projects'] ?? []) {
    const sshAlias = rp.hostId?.replace('remote-ssh-discovered:', '');
    if (sshAlias && await isSshConnectionAlive(sshAlias)) {
      projects.push({
        type: 'ssh-remote',
        id: rp.id,
        hostId: rp.hostId,
        remotePath: rp.remotePath,
        label: rp.label || sshAlias,
        sshConfig: await getSshConfigForAlias(sshAlias),
      });
    }
  }
  return projects;
}

2. Mobile client handles ssh-remote project type — render SSH projects in project selector with proxy connection through Mac host.

3. App-server proxy forwards SSH project connections — when mobile selects SSH remote, Mac app-server establishes/uses existing SSH connection.

Why This Works

  • SSH remote projects ARE discoverable from Mac (they're in global-state.json)
  • App-server proxy just needs to include them in the project list for mobile
  • Mobile client needs to handle the ssh-remote type
  • ~40 lines of TypeScript total, purely additive

Competitive Edge

  • Low competition (1 comment, no PRs)
  • Cross-component bug (iOS, app, app-server, remote)
  • User has detailed environment info and verified SSH connectivity

---

<details>
<summary>📋 Full Technical Analysis</summary>

Solution: openai/codex #23527 — SSH Remote Projects Not Visible on Mobile

Issue Summary

Codex mobile connects to macOS Codex App host successfully, and macOS can see SSH remote projects. However, SSH remote projects visible in the Mac Codex App do NOT appear in the ChatGPT mobile project selector for the same connected Mac host.

Root Cause Analysis

The Data Flow Gap

  1. Mac Codex App → discovers SSH remote projects via remote-projects config in ~/.codex/global-state.json
  2. App-server proxy → creates SSH app-server process from Mac to remote host
  3. Mobile remote-control enrollment → Mac host is enrolled, mobile can see it
  4. MISSING: SSH remote project list is NOT forwarded from Mac app-server to mobile via the remote-control protocol

Why This Happens

The remote-control protocol likely forwards:

  • ✅ Host availability (Mac is online)
  • ✅ Local projects from Mac's project-order
  • ❌ SSH remote projects from Mac's remote-projects config

The remote-projects array in global-state.json is app-local configuration. The app-server proxy process knows about these projects (it creates the SSH connections), but doesn't include them in the project list it sends to the mobile client.

Proposed Fix

Fix 1: Forward remote-projects through app-server protocol

// In app-server project discovery (macOS host side):
async function getProjectsForRemoteClient(): Promise<Project[]> {
  const globalState = await loadGlobalState();
  const projects: Project[] = [];
  
  // Local projects (already forwarded)
  for (const projectPath of globalState['project-order'] ?? []) {
    if (await directoryExists(projectPath)) {
      projects.push({ type: 'local', path: projectPath, label: path.basename(projectPath) });
    }
  }
  
  // SSH remote projects (NEW — currently missing)
  for (const remoteProject of globalState['remote-projects'] ?? []) {
    const sshAlias = remoteProject.hostId?.replace('remote-ssh-discovered:', '');
    if (sshAlias && await isSshConnectionAlive(sshAlias)) {
      projects.push({
        type: 'ssh-remote',
        id: remoteProject.id,
        hostId: remoteProject.hostId,
        remotePath: remoteProject.remotePath,
        label: remoteProject.label || sshAlias,
        // Include SSH connection details for mobile to establish direct connection
        sshConfig: await getSshConfigForAlias(sshAlias),
      });
    }
  }
  
  return projects;
}

Fix 2: Mobile client handles ssh-remote project type

// In mobile project selector:
function renderProject(project: Project) {
  switch (project.type) {
    case 'local':
      return <LocalProjectItem project={project} />;
    case 'ssh-remote':
      return <SshRemoteProjectItem 
        project={project}
        // Mobile connects through Mac host's app-server proxy to SSH remote
        proxyHost={currentEnrollment.serverName}
      />;
  }
}

Fix 3: App-server proxy forwards SSH project connections

When mobile selects an SSH remote project:

  1. Mobile sends connect(projectId) to Mac app-server
  2. Mac app-server looks up the project in its remote-projects config
  3. Mac app-server establishes/uses existing SSH connection to remote host
  4. All subsequent Codex CLI calls are proxied through Mac → SSH → remote host

Key Insight

The SSH remote projects ARE discoverable from the Mac host (they're in global-state.json). The app-server proxy just needs to include them in the project list it sends to mobile clients, and the mobile client needs to handle the ssh-remote project type.

Estimated Impact

  • Affects ALL users of Codex remote development workflow on mobile
  • Inconsistency between Mac and mobile UX (same host, different project visibility)
  • Fix is ~40 lines of TypeScript (app-server + mobile client)
  • Zero risk: purely additive — doesn't change existing local project handling

Competitive Analysis

  • Low competition: 1 comment (likely from user), no PRs
  • Labels: iOS, app, app-server, remote — cross-component bug
  • User has: detailed environment info, verified SSH connectivity, global-state.json contents

</details>

cheesea3 · 2 months ago

I have a workaround for users like @jameBoy who may be testing experimental or prerelease builds. It can be applied directly on a Linux remote host today, without rebuilding the Desktop app, patching the Desktop app-server source, or waiting for mobile/client parity to catch up.

Here’s the exact shim I use for standalone app-server binaries on my remote hosts, without including my personal tooling.

Install this minimal Codex remote shim

  • The script assumes that Python 3 is installed and accessible as python3 in your PATH.
  • Make sure the standalone codex-app-server binary exists at the expected location:
~/.local/bin/codex-app-server

Check first:

ls -l ~/.local/bin/codex-app-server

Create the shim:

mkdir -p ~/.local/bin

cat > ~/.local/bin/codex <<'EOF'
#!/usr/bin/env sh
set -eu

APP_SERVER="${CODEX_APP_SERVER_BIN:-$HOME/.local/bin/codex-app-server}"
CLI_VERSION="${CODEX_REMOTE_SHIM_VERSION:-codex-cli 0.131.0}"

usage() {
  cat >&2 <<'USAGE'
This host has the minimal Codex remote shim installed.
Supported:
  codex --version
  codex app-server --listen ...
  codex app-server proxy [--sock SOCKET_PATH]
USAGE
}

proxy_stdio_to_uds() {
  sock=""
  while [ "$#" -gt 0 ]; do
    case "$1" in
      --sock)
        shift
        if [ "$#" -eq 0 ]; then
          echo "codex app-server proxy: --sock requires a path" >&2
          exit 2
        fi
        sock="$1"
        ;;
      --help|-h)
        echo "Usage: codex app-server proxy [--sock SOCKET_PATH]"
        exit 0
        ;;
      *)
        echo "codex app-server proxy: unexpected argument '$1'" >&2
        exit 2
        ;;
    esac
    shift
  done

  if [ -z "$sock" ]; then
    sock="${CODEX_HOME:-$HOME/.codex}/app-server-control/app-server-control.sock"
  fi

  CODEX_REMOTE_SHIM_PROXY_SOCK="$sock" exec python3 -c '
import os
import select
import socket

sock_path = os.environ["CODEX_REMOTE_SHIM_PROXY_SOCK"]
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client.connect(sock_path)
client_fd = client.fileno()
stdin_open = True

try:
    while True:
        read_fds = [client_fd]
        if stdin_open:
            read_fds.append(0)
        ready, _, _ = select.select(read_fds, [], [])

        if 0 in ready:
            data = os.read(0, 65536)
            if data:
                client.sendall(data)
            else:
                stdin_open = False
                try:
                    client.shutdown(socket.SHUT_WR)
                except OSError:
                    pass

        if client_fd in ready:
            data = client.recv(65536)
            if not data:
                break
            os.write(1, data)
finally:
    client.close()
'
}

app_server_uses_unix_listener() {
  prev_was_listen=0
  for arg in "$@"; do
    case "$arg" in
      --listen=unix://*)
        return 0
        ;;
      --listen)
        prev_was_listen=1
        continue
        ;;
      unix://*)
        if [ "$prev_was_listen" -eq 1 ]; then
          return 0
        fi
        ;;
    esac
    prev_was_listen=0
  done
  return 1
}

case "${1:-}" in
  --version|-V)
    echo "$CLI_VERSION"
    ;;
  app-server)
    shift
    case "${1:-}" in
      --version|-V)
        echo "$CLI_VERSION"
        ;;
      proxy)
        shift
        proxy_stdio_to_uds "$@"
        ;;
      *)
        if app_server_uses_unix_listener "$@"; then
          exec "$APP_SERVER" --remote-control "$@"
        fi
        exec "$APP_SERVER" "$@"
        ;;
    esac
    ;;
  *)
    usage
    exit 2
    ;;
esac
EOF

chmod 755 ~/.local/bin/codex

Make sure ~/.local/bin is on PATH:

case ":$PATH:" in
  *":$HOME/.local/bin:"*) echo "PATH ok" ;;
  *) echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.profile ;;
esac

For the current shell:

export PATH="$HOME/.local/bin:$PATH"

Verify:

command -v codex
codex --version
codex app-server proxy --help

Expected:

/home/<user>/.local/bin/codex
codex-cli 0.131.0
Usage: codex app-server proxy [--sock SOCKET_PATH]

Verify the real app-server exists:

ls -l ~/.local/bin/codex-app-server

If the app-server binary lives somewhere else, either edit this line in the shim:

APP_SERVER="${CODEX_APP_SERVER_BIN:-$HOME/.local/bin/codex-app-server}"

or set an environment variable:

export CODEX_APP_SERVER_BIN=/path/to/codex-app-server

What this does

When Codex Desktop asks the SSH host to run:

codex app-server --listen unix://

the shim launches:

~/.local/bin/codex-app-server --remote-control --listen unix://

That makes the remote Linux host enroll as its own mobile-visible remote-control device instead of relying on the old:

[features]
remote_control = true

config path.

The simple shim above does not require anything else. For this specific mobile/remote-control workaround, the only important part is that the SSH host has a standalone codex-app-server binary and that the codex shim turns:

codex app-server --listen unix://

into:

codex-app-server --remote-control --listen unix://

That bypasses the stale [features].remote_control = true path and makes the SSH host enroll directly as a remote-control device or ssh remote connection with the latest version of the Codex Desktop app. Both options become possible, with it also being accessible from the ChatGPT mobile app. Adjust my shim as needed to suit your own requirements.

cheesea3 · 2 months ago

https://github.com/openai/codex/issues/23527#issuecomment-4493602069

The key difference I found is between 0.130.0 and 0.131.0.

With 0.130.0, [features].remote_control = true still works as the old live control path. I was able to get a remote SSH host visible from mobile and toggle remote control successfully.

With 0.131.0, remote_control under [features] was intentionally moved to removed/inert compatibility behavior. Config APIs can still read/write it, but that write no longer controls remote-control process enrollment. That maps directly to the Desktop toggle flashing back off / doing nothing on a plain 0.131.0 remote host.

When I traced the Desktop switch, I saw this on enable:

config/batchWrite features.remote_control=true
remoteControl/status/changed status=connecting
GET /backend-api/wham/remote/control/server
HTTP error: 409 Conflict
body: {"detail":"Remote app server already online"}
remoteControl/status/changed status=errored

And this on disable:

config/batchWrite features.remote_control=false
remoteControl/status/changed targeted_connections=0
remote control websocket reader was stopped
remoteControl/status/changed status=disabled

So the Settings switch is still writing features.remote_control = true/false.

That worked in the 0.130.0 world, but in 0.131.0 app-server startup now needs the runtime/process path instead:

codex app-server --remote-control --listen unix://

Related changes:

  • remoteControl/enable, remoteControl/disable, and remoteControl/status/read

landed in the runtime API work: #22578 / #22877

  • codex remote-control moved toward daemon-managed startup: #22218 / #22562
  • 0.131.0 release notes mention daemon-managed codex remote-control,

runtime enable/disable APIs, and status reads.

On a normal standalone install like:

~/.local/bin/codex -> ~/.codex/packages/standalone/current/codex
current -> releases/0.131.0-x86_64-unknown-linux-musl

Desktop starts the remote process as:

codex app-server --listen unix://
codex app-server proxy

not:

codex app-server --remote-control --listen unix://

My workaround was to use a small remote codex shim. The shim implements only the surface the Desktop SSH flow needs:

codex --version
codex app-server --listen ...
codex app-server proxy [--sock SOCKET_PATH]

and when Desktop asks for:

codex app-server --listen unix://

the shim actually launches:

codex-app-server --remote-control --listen unix://

That makes the Linux SSH host enroll as its own mobile-visible remote-control device. It does not rely on the Mac app forwarding a nested SSH project, and it does not rely on the deprecated/inert [features].remote_control path.

The Desktop UI may still show "Allow other devices" as disabled or flash the toggle back off, because that UI path is not fully aligned with the 0.131.0 remote-control runtime model yet. But the remote host can still show up on mobile because the app-server process was started with --remote-control.

So I think there are two separate lanes here:

Product level fix:
Dependent on OpenAI team unless you start patching yourself.

Operational workaround:
  Make the SSH host enroll directly by launching its app-server with
  `--remote-control`, using a shim if necessary.

One other compatibility issue I discovered during my analysis was version reporting. The Codex Desktop SSH connections panel appears to care about the exact codex --version text returned by the remote host, not just whether the app-server behavior is actually present.

For example, one of my earlier remote shims reported:

codex-cli 0.130.0-alpha.5

and Codex Desktop surfaced it as:

Codex on this environment is out of date. Update to 0.130.0 or newer.
Current version: 0.130.0-alpha.5

That was a useful clue: even though 0.130.0-alpha.5 looks close to 0.130.0, Desktop treated the prerelease string as below the stable compatibility floor.

The same class of problem can happen with local/custom builds off the latest commit in main-branch.

Depending on how the build metadata is stamped, my locally built Codex artifacts first reported odd versions like:

codex-cli 0.0

and the built artifact prerelease provided on github included strings like:

codex-cli 0.131.0-alpha.9

Those version strings can still trip the compatibility/status logic in the SSH remote connections panel, even if the app-server binary itself has the feature behavior you need because that's how the desktop app is developed currently.

That is why the shim’s version response is not just cosmetic. In my shim I make it report a stable-looking version:

CLI_VERSION="${CODEX_REMOTE_SHIM_VERSION:-codex-cli 0.131.0}"

This does not mean the remote has the full stock CLI installed. It is a Desktop/mobile compatibility response for the SSH handshake. The actual app-server is still the standalone codex-app-server binary behind the shim.

For anyone testing this on a normal remote host, I would recommend getting off alpha/dev builds first:

codex update
codex --version

Expected:

codex-cli 0.131.0

That avoids prerelease/build-version weirdness before debugging the separate remote-control behavior.

If someone wants the least weird path today and does not need the latest version, staying on remote codex-cli 0.130.0 is the easiest option. In 0.130.0, the old features.remote_control=true flag still works and remains in parity with the Desktop app switch toggle.

If you want to test 0.131.0 or later, the shim/runtime-flag path is more reliable than expecting features.remote_control=true to keep working.

Yes, you can use the daemon path with the full CLI instead. But in practice, you can also run the standalone Desktop-launched/nohup-style detached app-server process instead of using the codex app-server daemon / codex remote-control start daemon path.

It is already daemon-like because it is backgrounded with nohup and survives the SSH launcher, but it is not daemon-managed. The shim injects --remote-control when Desktop starts:

codex app-server --listen unix://

so the real process becomes:

codex-app-server --remote-control --listen unix://

with a live Unix control socket.

You do not need the daemon management commands for this workaround. You only need listener mode plus the app-server's internal remote-control protocol support, which lets the host behave like a persistent remote-control device.

One note: the public help text for the standalone app-server binary does not currently advertise --remote-control:

~/.local/bin/codex-app-server --help

but the app-server accepts the flag when the shim launches it with --remote-control.

cheesea3 · 2 months ago

https://github.com/openai/codex/issues/23527#issuecomment-4493613001

I use this shim as part of a separate setup that is a bit ahead of the current out-of-the-box Codex flow. In that setup, I run a patched standalone executor on an internal VM:

/home/me/.local/bin/codex-exec-server --listen stdio://

with a helper binary beside it:

/home/me/.local/bin/codex-linux-sandbox

That lets my main "bastion" remote host control internal VMs as execution environments. Instead of the remote connection falling back to one-off SSH commands into those VMs, the bastion can expose normal Codex tooling while execution happens inside any of my internal VMs.

In my setup, the internal VMs sit behind the bastion on a private bridge, and the bastion can provide routing/egress when needed. The result is one remote Codex control plane on the bastion, with multiple internal execution environments behind it, without losing the native Codex tooling. The reason this matters is that each Codex thread is normally tied to a single remote connection. When working across multiple VMs, that creates a context gap: either you open separate remote connections and split the work across threads, or you stay on the bastion and use plain SSH commands, losing the native Codex tool surface inside the VM.

My patched route tries to avoid that. The Desktop app connects to the bastion, but the bastion app-server can route tool execution into a configured internal VM environment. That keeps the work controllable from one thread while still letting the execution happen inside the VM.

The basic goal was to avoid installing the full Codex CLI or a full app-server on every internal VM. The internal nodes only need the executor side: a standalone codex-exec-server binary, the codex-linux-sandbox helper beside it so filesystem and patch operations could work correctly, SSH access from the bastion, and an environment entry on the bastion that launches the executor over SSH.

To make that work, I created a standalone codex-exec-server binary wrapper. Because upstream mostly treats exec-server as part of the larger Codex stack, I had to patch the MUSL build dependency shape to make the executor standalone. So after generating a tiny standalone codex-exec-server binary wrapper in a temporary overlay, the upstream source tree stays read-only/unmodified; the wrapper is added only in the overlay. That wrapper supports the executor listen path I need:

codex-exec-server --listen stdio://

To process all this, I built a standalone virtual machine MUSL build pipeline. The flow fetches an immutable upstream openai/codex source archive, verifies it, transfers it to the virtual machine, extracts it into a versioned source tree, and builds inside a containerized MUSL lane instead of installing build dependencies directly on the VM host. Output artifacts go into a dedicated build-output directory, with hashes, file metadata, and provenance sidecars recorded.

The build flow fetches a pinned upstream Codex source archive, verify it, transfer/build in the internal VM build environment, produce MUSL artifacts, hash them, and record provenance so I knew exactly which upstream commit and patchset produced the binaries. That mattered because I was not just installing a normal CLI release; I was producing standalone app-server/exec-server artifacts with controlled patches.

The standalone executor build also needed dependency work. The first standalone build pulled OpenSSL through reqwest default features because it did not include the larger app-server/core graph that normally handles that dependency shape. I patched the overlay build to use default-features = false with rustls-tls for that standalone executor build.

There were also wrapper/runtime fixes. The standalone executor must ship with codex-linux-sandbox beside it and pass that helper path into ExecServerRuntimePaths, otherwise filesystem and patch operations fail with:

failed to prepare fs sandbox: missing codex-linux-sandbox executable path

I also had to handle the hidden filesystem-helper mode before starting Tokio. The first helper-aware wrapper still failed with:

Cannot start a runtime from within a runtime

so the generated wrapper now detects the filesystem-helper arg first, runs that helper path directly, and only starts Tokio for normal --listen or --remote exec-server modes.

On the app-server side, I first tested shallower patches and learned they were not enough. Patching command/exec alone did not move the native Codex tool calls into my other virtual machine used for testing from my main "bastion/orchestrator" virtual machine, because the active tool path was going through the runtime/unified-exec path instead. I then moved the patch to the lower execution seam where requests are converted and forwarded to the remote exec-server. In my validated setup, the default environment points to a configured execution environment instead of always implicitly executing locally, but my model supports multiple configured VM environments behind the bastion.

I caught another issue through a debug wrapper I had setup during a dry run while on my testing virtual machine which helped captured the raw JSON-RPC stream and showed the real failure: The virtual machine was receiving process/start with a bastion-local sandbox wrapper in the argv, including paths like:

/home/me/.codex/tmp/arg0/.../codex-linux-sandbox
--apply-seccomp-then-exec
--
/bin/bash -lc ...

or later, the same shape without the --apply-seccomp-then-exec marker:

codex-linux-sandbox ... -- /bin/bash -lc ...

That explained the No such file or directory failures: The internal virtual machine where I was testing the built version was trying to execute a sandbox helper path that only existed on the bastion. The app-server/exec forwarding patch now detects those bastion-local sandbox wrappers, strips them, and forwards only the real inner command to the selected internal VM environment.

The final patch bundle also lets the bastion app-server default tool execution into a configured environment, using environment variables such as:

CODEX_APP_SERVER_TOOL_ENVIRONMENT=default
CODEX_APP_SERVER_TOOL_REMOTE_CWD=/home/me
CODEX_APP_SERVER_REMOTE_STRIP_LOCAL_SANDBOX=1

After that, I validated more than just "can it run hostname." I validated command execution and file/editor semantics separately. The final checks proved:

environment_id=local        -> runs on bastion
environment_id=testing-vm-> runs on testing VM

and validated apply_patch add/update/delete behavior against the testing VM environment, which is where the codex-linux-sandbox helper became necessary.

I also pinned the known-good patchset instead of leaving this as one-off source edits. The validated patchset is tied to a specific upstream commit, artifact hashes, patch hash, and tooling hashes. The current known-good patchset records the [my internal software name] routing patch, the app-server artifact, the standalone exec-server artifact, and the codex-linux-sandbox helper. I added lifecycle/debug helpers so future upstream refreshes can run a lineage audit, scaffold a new patchset, rebuild, redeploy, and revalidate command plus filesystem/editor semantics before marking anything as known-good.

Validation helped confirm that environment_id=local executed on the bastion, environment_id=testing-vm executed inside the configured VM, filesystem operations worked, patch add/update/delete semantics worked, the sandbox helper was available, and the app-server/proxy/control-socket lifecycle still behaved correctly.

I'm leaving my insights here for anyone curious on how to take things further when using non-stable versions, and how I came across this exact problem with my work with constraints, like keeping the internal nodes lean using a dogfooding approach to build out my own workflow narrowly etc. I like that the internal VM(s) do not need the CLI at all nonetheless a full app-server install. It only needs my standalone patched codex-exec-server binary, the codex-linux-sandbox helper beside it, SSH connectivity from the bastion, and an environment entry configured on the bastion that launches that executor over SSH.

Using direct SSH from the ChatGPT app can reach the host, but it does not give you the same single-thread Codex control plane with routed tool execution across internal VM environments.

With the approach I described, the Desktop/mobile connection talks to one remote Codex control plane, while that control plane can route execution into different internal VMs and still preserve the native Codex tooling surface, including things like applying patches.

My personal goal when I came across OP’s concern was to keep my workflow usable from both mobile and the Desktop app as native control devices, without patching the Desktop app and without needing to SSH directly from my phone, while staying inside one coherent Codex thread even when moving back and forth across multiple virtual machines.

Snailflyer · 2 months ago

Your repro points to a project-discovery boundary rather than a remote runtime boundary: the Mac host can see and run the SSH project, but the mobile selector does not surface the host's remote-ssh-discovered:* projects.

A practical distinction for the mobile design is whether the phone is controlling a Mac host that then proxies to SSH, or whether the owner process is already running on the Linux/SSH machine and the phone only attaches to that live process. The second model avoids depending on the Mac app's project selector to mirror remote SSH state.

I built Faryo as a narrow open-source route for that second model: run Codex/Claude/shell inside tmux on the owner machine, then use a phone/browser workbench for compact output, short input, approvals/interrupts, and handoff. It is not a native Codex mobile fix, but for Linux/tmux-first remote projects it may be a useful workaround/comparison: https://github.com/Snailflyer/faryo

nelhu · 2 months ago

I ran into this issue too. Later I found that it was caused by a corrupted state_5.sqlite, which made codex app-server fail to start. In the end, I also had to reset remote_control so the app and client could reconnect properly and fully resolve the problem.
https://github.com/openai/codex/issues/23247#issuecomment-4505637794

damaged-soda · 25 days ago

Additional repro from another setup, on newer builds.

Environment:

  • Codex App for macOS: 26.616.81150 build 4306
  • Bundled/local Codex CLI on Mac host: codex-cli 0.142.0
  • Mac host OS: macOS 26.5.1 build 25F80, arm64
  • Remote SSH hosts: Linux x86_64
  • Remote Codex CLI versions tested: codex-cli 0.141.0 and codex-cli 0.142.0
  • ChatGPT iOS app: current App Store build as of 2026-06-25, exact version not captured
  • Same ChatGPT account/workspace on macOS Codex App and iOS ChatGPT

The Mac host can use the SSH remote project normally from Codex Desktop. The SSH host is configured in ~/.ssh/config, Codex is installed on the remote login shell, and Codex Desktop has active SSH app-server/proxy processes for the remote hosts.

The Mac host's Codex state contains both discovered SSH remote connections and a saved SSH remote project. Redacted shape:

{
  "codex-managed-remote-connections": [
    {
      "hostId": "remote-ssh-discovered:<ssh-alias-a>",
      "displayName": "<ssh-alias-a>",
      "source": "discovered",
      "alias": "<ssh-alias-a>"
    },
    {
      "hostId": "remote-ssh-discovered:<ssh-alias-b>",
      "displayName": "<ssh-alias-b>",
      "source": "discovered",
      "alias": "<ssh-alias-b>"
    }
  ],
  "remote-projects": [
    {
      "id": "<remote-project-id>",
      "hostId": "remote-ssh-discovered:<ssh-alias-a>",
      "remotePath": "/home/<user>/work/<repo>",
      "label": "<repo>"
    }
  ],
  "project-order": [
    "<remote-project-id>",
    "/Users/<mac-user>/work/<local-repo>"
  ],
  "codex-mobile-has-connected-device": true,
  "electron-local-remote-control-environment-id": "env_e_<redacted>"
}

A/B reproduction:

  1. In Codex Desktop on the Mac host, open the saved SSH remote project and send a trivial message (你好) in a new thread.
  2. In Codex Desktop on the same Mac host, open a normal local project and send the same trivial message (你好) in a new thread.
  3. Open ChatGPT iOS, go to Codex, and select the connected Mac host.

Observed:

  • The local project thread appears on iOS.
  • The SSH remote project thread does not appear on iOS.
  • The SSH remote project itself is also not visible as a selectable project from iOS.

This makes the failure narrower than a general remote-control or account/workspace sync issue: mobile remote control can see the connected Mac host and can sync local-project threads from that host, but it does not surface the Mac host's saved remote-ssh-discovered:* project or threads created under that project.

This matches the earlier diagnosis in this issue: the missing boundary appears to be forwarding/preserving the Mac host's SSH remote project identity through mobile project/thread discovery and resume.

cheesea3 · 25 days ago

@damaged-soda

One useful follow-up from my side is that your repro appears to be hitting a different boundary than the one I worked around.

You are running real 0.141.x / 0.142.x remote binaries and still reproducing a Mobile/SSH project visibility failure on newer builds. Your repro strongly suggests that Mobile does not currently preserve or expose Desktop-managed remote-ssh-discovered:* project identity across the Desktop -> Mobile boundary. That suggests this is not just a stale remote CLI/app-server problem. It looks more like a product-level gap in how Mobile discovers Desktop-managed projects, including nested SSH remote projects.

In my setup, I am currently on Codex Desktop Beta 26.611.61753 with a patched 0.134.0-alpha.3 app-server underneath. Because newer Desktop builds tightened compatibility checks, my older remote app-server stopped working until I updated the compatibility layer. My architecture made the remote server itself the Mobile-visible remote-control environment while still allowing Codex Desktop to use it as a normal SSH remote connection.

First, here is what I noticed at the boundaries my workaround hit.

The Desktop-side break was not only codex --version. Newer Desktop also inspected initialize.result.userAgent after the websocket/control connection opened. So faking codex --version was no longer sufficient. The app-server initialization identity also had to begin with a compatible >= 0.139.0 token. I solved that with an originator/user-agent compatibility mask while still running my validated patched 0.134.0-alpha.3 app-server underneath.

That restored Desktop remote connection behavior, but it did not solve Mobile.

Mobile was a different path. It was not using the SSH bootstrap surface. It discovered my remote server through remote-control backend enrollment. The live app-server still enrolled with its compiled metadata:

app_server_version: 0.134.0-alpha.3

The shell shim could not change that field. The fix was a narrow loopback forwarder between app-server and ChatGPT backend-api that rewrites only the remote-control enrollment payload:

POST /backend-api/wham/remote/control/server/enroll
app_server_version: 0.134.0-alpha.3 -> 0.139.0

Everything else, including ordinary HTTP traffic, auth, thread state, websocket payloads, and remote-control messaging, is relayed without semantic rewriting. I did have to fix the websocket relay implementation itself after real Mobile send traffic exposed a relay stability bug, but that was a transport bug, not another compatibility payload rewrite.

So the newer compatibility picture in my observed setup became:

Desktop compatibility required:

  codex --version >= 0.139.0
  initialize.result.userAgent >= 0.139.0

Mobile/backend enrollment compatibility required:

  app_server_version >= 0.139.0

Mobile send-path stability also required:

  a stable websocket relay that survives real remote-control traffic

Those turned out to be completely independent compatibility surfaces. Restoring Desktop compatibility did not restore Mobile compatibility, which is what led me to separate the investigation into distinct boundaries instead of treating "remote-control" as one monolithic feature.

I did not promote my runtime to a real 0.139.0 app-server. I kept the patched 0.134.0-alpha.3 runtime because it carries my app-server/exec-server routing fabric. I only added compatibility masks at the places newer clients/backend started enforcing version identity.

The other important detail is that the working Mobile path used my normal Codex home and existing state database, not an isolated canary home. My isolated canary successfully enrolled, but because it intentionally used a separate CODEX_HOME, it also had a separate state database and therefore no existing projects or thread history. That told me the remaining problem was no longer enrollment itself, but continuity with the existing runtime state.

The useful path was:

normal ~/.codex state
+ remote-control enrollment compatibility rewrite
+ stable websocket relay
= Mobile could see the real existing threads and send into a real existing thread

For your issue, though, the important distinction is this:

Your repro says Mobile can see the connected Mac host and local project threads, but cannot see the Mac Desktop's saved remote-ssh-discovered:* SSH project or its threads. That appears to be a Desktop-to-Mobile SSH project identity propagation boundary rather than a basic remote-control or account synchronization problem.

My setup avoids that nested boundary entirely by making the remote bastion enroll as its own remote-control environment instead of expecting the Mac host to forward a saved SSH remote project into Mobile.

A useful A/B test would therefore be to launch the SSH host's app-server directly with --remote-control and see whether that host appears to Mobile as its own remote-control environment. If it does, that would support the theory that the remaining issue is specific to Desktop-managed SSH project propagation rather than remote-control itself.

Since you are already on real 0.141.x / 0.142.x binaries, you probably do not need the compatibility masks I added for my older patched runtime. My compatibility work existed to preserve a validated 0.134.0-alpha.3 app-server while newer Desktop and Mobile compatibility checks evolved. Your setup is already beyond that point.

If direct remote-control enrollment works for your SSH host, it would structurally represent a different architecture rather than a fix for the underlying Desktop-managed SSH project propagation behavior.

It changes the topology:

Instead of relying on Desktop to propagate a saved SSH project into Mobile, the SSH host itself becomes the remote-control environment that Mobile connects to directly.

That means the motivation for direct enrollment does not have to be tied to solving that particular product boundary; it can stand on its own as a cleaner deployment topology, even if the underlying product behavior remains unchanged.

One thing I found useful throughout this work was treating Desktop compatibility, Mobile enrollment compatibility, and remote execution routing as three independent boundaries. Fixing one did not imply the others were solved, and separating them made it much easier to understand where newer releases were actually introducing compatibility requirements.

kendonB · 9 days ago

Still an issue on Android->Windows->Linux, hopefully the slop posts above don't mean this issue gets ignored