app-server WS handshake fails with iOS URLSessionWebSocketTask (-1005) before initialize

Open 💬 5 comments Opened Feb 19, 2026 by shuto-S

Summary

When connecting to codex app-server from an iOS client (URLSessionWebSocketTask), TCP connection reaches the server, but the WebSocket session drops immediately on iOS with NSURLErrorDomain Code=-1005 before JSON-RPC initialize can start.

Environment

  • Codex CLI: codex-cli 0.104.0
  • Server start command:

``bash
codex app-server --listen ws://0.0.0.0:8080
``

  • Listener check:

``bash
lsof -nP -iTCP:8080 -sTCP:LISTEN
# codex-aar ... TCP *:8080 (LISTEN)
``

  • Client: iOS app (Swift + URLSessionWebSocketTask)
  • Network: same Tailscale tailnet (closed/private network)

Reproduction

  1. Start server:

``bash
codex app-server --listen ws://0.0.0.0:8080
``

  1. Connect from iOS app via URLSessionWebSocketTask to either:
  • ws://{host}:{port}
  1. Attempt to connect and begin app-server session.

Expected

WebSocket handshake succeeds and app-server can proceed to JSON-RPC initialize.

Actual

iOS immediately reports connection lost (-1005) and app-server session cannot initialize.

iOS logs

Task <09A1000B-A247-490A-8542-EFBC39F47539>.<4> HTTP load failed, 696/0 bytes (error code: -1005 [4:-4])
Task <09A1000B-A247-490A-8542-EFBC39F47539>.<4> finished with error [-1005] Error Domain=NSURLErrorDomain Code=-1005 "The network connection was lost." UserInfo={NSErrorFailingURLStringKey=http://100.103.155.65:8080/, NSErrorFailingURLKey=http://100.103.155.65:8080/, _NSURLErrorRelatedURLSessionTaskErrorKey=(
    "LocalWebSocketTask <09A1000B-A247-490A-8542-EFBC39F47539>.<4>"
), _NSURLErrorFailingURLSessionTaskErrorKey=LocalWebSocketTask <09A1000B-A247-490A-8542-EFBC39F47539>.<4>, NSLocalizedDescription=The network connection was lost.}
Task <CFFE1E8F-BF3E-4E35-B2C5-57508D91EFA0>.<5> HTTP load failed, 696/0 bytes (error code: -1005 [4:-4])
Task <CFFE1E8F-BF3E-4E35-B2C5-57508D91EFA0>.<5> finished with error [-1005] Error Domain=NSURLErrorDomain Code=-1005 "The network connection was lost." UserInfo={NSErrorFailingURLStringKey=http://100.103.155.65:8080/, NSErrorFailingURLKey=http://100.103.155.65:8080/, _NSURLErrorRelatedURLSessionTaskErrorKey=(
    "LocalWebSocketTask <CFFE1E8F-BF3E-4E35-B2C5-57508D91EFA0>.<5>"
), _NSURLErrorFailingURLSessionTaskErrorKey=LocalWebSocketTask <CFFE1E8F-BF3E-4E35-B2C5-57508D91EFA0>.<5>, NSLocalizedDescription=The network connection was lost.}

app-server logs

codex app-server (WebSockets)
  listening on: ws://0.0.0.0:8080
  note: this is a raw WS server; consider running behind TLS/auth for real remote use
websocket client connected from 100.103.155.65:64251
websocket client connected from 100.103.155.65:64254

(Repeated attempts show many websocket client connected ... lines, but iOS side still fails before usable session.)

Notes / suspicion

I suspect a handshake compatibility problem around Sec-WebSocket-Extensions (e.g. permessage-deflate) from iOS URLSessionWebSocketTask, which cannot easily disable extension negotiation.

Could app-server either:

  1. Tolerate/ignore unsupported extension negotiation from clients like iOS URLSession, or
  2. Offer a flag/config to disable extension negotiation requirements for compatibility?

Happy to test a branch/build if needed.

View original on GitHub ↗

5 Comments

cl1107 · 6 months ago

@shuto-S Thanks for closing this issue. i'm still hitting what looks like the same handshake problem from React Native (Expo), and wanted to ask how this was resolved on your side.

Environment:

  • codex-cli: 0.104.0
  • server: codex app-server --listen ws://192.168.11.229:9000
  • client: React Native WebSocket (iOS/Android), same LAN

Current behavior:

  • TCP reaches server, but WS fails before initialize
  • server log:

failed to complete websocket handshake: WebSocket protocol error: Missing, duplicated or incorrect header sec-websocket-extensions

Important detail:

  • In React Native’s native WebSocket API, we cannot control/disable per-message deflate or extension negotiation.
  • As a control test, a Node ws client with perMessageDeflate: false can connect and complete:

initialize -> initialized -> thread/list.

Could you clarify what fix/workaround was used when closing this?

  1. Was app-server changed to tolerate Sec-WebSocket-Extensions from iOS/RN clients?
  2. Or is the recommended workaround still to use a proxy/relay (stdio-to-ws, custom WS bridge, etc.)?
  3. If there is a recommended mobile-compatible client stack, could you share it?

Thanks!

shuto-S · 6 months ago

@cl1107 Thanks for the question — and sorry, I forgot to include the solution earlier.

I run Codex app-server behind a small WebSocket proxy:

[ws_strip_extensions_proxy.js](vscode-file://vscode-app/Applications/Visual%20Studio%20Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html) (header-normalizing proxy)
run_app_server_stack.sh (starts/monitors app-server + proxy together)
This setup avoids connecting React directly to codex app-server and normalizes the handshake request before forwarding upstream.

What it covers:

  • Removes Sec-WebSocket-Extensions before forwarding
  • Rewrites Host for the upstream target
  • Preserves client path/query by default (or uses explicit upstream path/query if configured)
  • Starts both processes in order, monitors them, and stops both cleanly on Ctrl+C
  • Splits logs for easier troubleshooting (app-server vs proxy)

In short, this mainly mitigates handshake failures caused by extension-header incompatibility (e.g., permessage-deflate negotiation mismatch).
It does not by itself solve unrelated issues such as Origin policy, auth headers, or TLS configuration.

If you know a better way, I’d love to hear it.

ws_strip_extensions_proxy.js

#!/usr/bin/env node
"use strict";

const net = require("node:net");
const tls = require("node:tls");

const MAX_HEADER_BYTES = 64 * 1024;

function usage() {
  console.log(
    [
      "Usage:",
      "  node scripts/ws_strip_extensions_proxy.js [--upstream ws://127.0.0.1:8080] [--listen-host 0.0.0.0] [--listen-port 18081]",
      "",
      "Options:",
      "  --upstream     Upstream codex app-server URL (ws:// or wss://)",
      "  --listen-host  Local bind host (default: 0.0.0.0)",
      "  --listen-port  Local bind port (default: 18081)",
      "  --help         Show this help",
    ].join("\n")
  );
}

function parseArgs(argv) {
  const result = {
    upstream: "ws://127.0.0.1:8080",
    listenHost: "0.0.0.0",
    listenPort: 18081,
  };

  for (let index = 0; index < argv.length; index += 1) {
    const arg = argv[index];
    if (arg === "--help" || arg === "-h") {
      usage();
      process.exit(0);
    }

    if (arg === "--upstream") {
      result.upstream = argv[index + 1] ?? "";
      index += 1;
      continue;
    }
    if (arg === "--listen-host") {
      result.listenHost = argv[index + 1] ?? "";
      index += 1;
      continue;
    }
    if (arg === "--listen-port") {
      result.listenPort = Number(argv[index + 1]);
      index += 1;
      continue;
    }

    console.error(`Unknown argument: ${arg}`);
    usage();
    process.exit(2);
  }

  if (!Number.isInteger(result.listenPort) || result.listenPort < 1 || result.listenPort > 65535) {
    throw new Error("listen-port must be an integer between 1 and 65535.");
  }

  let upstreamURL;
  try {
    upstreamURL = new URL(result.upstream);
  } catch {
    throw new Error(`Invalid upstream URL: ${result.upstream}`);
  }

  if (upstreamURL.protocol !== "ws:" && upstreamURL.protocol !== "wss:") {
    throw new Error("upstream URL must use ws:// or wss://");
  }

  result.upstreamURL = upstreamURL;
  return result;
}

function respondAndClose(socket, statusLine) {
  if (socket.destroyed) {
    return;
  }
  socket.end(`${statusLine}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
}

function parseRequest(rawHeaders) {
  const lines = rawHeaders.split("\r\n");
  const requestLine = lines.shift() ?? "";
  const [method, path, version] = requestLine.split(" ");

  if (method !== "GET" || !path || version !== "HTTP/1.1") {
    return null;
  }

  const headers = [];
  for (const line of lines) {
    if (!line) {
      continue;
    }
    const separator = line.indexOf(":");
    if (separator === -1) {
      continue;
    }
    const name = line.slice(0, separator).trim();
    const value = line.slice(separator + 1).trim();
    headers.push([name, value]);
  }

  return { path, headers };
}

function selectedUpstreamPath(upstreamURL, clientPath) {
  const hasCustomPath = upstreamURL.pathname && upstreamURL.pathname !== "/";
  const hasCustomQuery = upstreamURL.search && upstreamURL.search.length > 0;
  if (hasCustomPath || hasCustomQuery) {
    return `${upstreamURL.pathname || "/"}${upstreamURL.search || ""}`;
  }
  return clientPath;
}

function connectUpstream(upstreamURL) {
  const port =
    upstreamURL.port !== ""
      ? Number(upstreamURL.port)
      : upstreamURL.protocol === "wss:"
      ? 443
      : 80;
  if (upstreamURL.protocol === "wss:") {
    return tls.connect({
      host: upstreamURL.hostname,
      port,
      servername: upstreamURL.hostname,
    });
  }
  return net.connect({
    host: upstreamURL.hostname,
    port,
  });
}

function onConnection(clientSocket, config) {
  const remote = `${clientSocket.remoteAddress ?? "unknown"}:${clientSocket.remotePort ?? 0}`;
  let buffered = Buffer.alloc(0);
  let proxied = false;

  const onData = (chunk) => {
    if (proxied) {
      return;
    }
    buffered = Buffer.concat([buffered, chunk]);
    if (buffered.length > MAX_HEADER_BYTES) {
      console.warn(`[ws-proxy] dropped ${remote}: request headers too large`);
      respondAndClose(clientSocket, "HTTP/1.1 431 Request Header Fields Too Large");
      return;
    }

    const markerIndex = buffered.indexOf("\r\n\r\n");
    if (markerIndex === -1) {
      return;
    }

    clientSocket.removeListener("data", onData);
    const headerRaw = buffered.subarray(0, markerIndex).toString("latin1");
    const bodyRemainder = buffered.subarray(markerIndex + 4);
    const request = parseRequest(headerRaw);
    if (!request) {
      respondAndClose(clientSocket, "HTTP/1.1 400 Bad Request");
      return;
    }

    const outboundHeaders = [];
    for (const [name, value] of request.headers) {
      const lower = name.toLowerCase();
      if (lower === "host") {
        continue;
      }
      if (lower === "sec-websocket-extensions") {
        continue;
      }
      outboundHeaders.push([name, value]);
    }
    outboundHeaders.push(["Host", config.upstreamURL.host]);

    if (clientSocket.remoteAddress) {
      outboundHeaders.push(["X-Forwarded-For", clientSocket.remoteAddress]);
    }

    const outboundPath = selectedUpstreamPath(config.upstreamURL, request.path);
    const outboundRequest = [
      `GET ${outboundPath} HTTP/1.1`,
      ...outboundHeaders.map(([name, value]) => `${name}: ${value}`),
      "",
      "",
    ].join("\r\n");

    const upstreamSocket = connectUpstream(config.upstreamURL);
    const closeBoth = () => {
      if (!clientSocket.destroyed) {
        clientSocket.destroy();
      }
      if (!upstreamSocket.destroyed) {
        upstreamSocket.destroy();
      }
    };

    upstreamSocket.once("error", (error) => {
      console.warn(`[ws-proxy] upstream error for ${remote}: ${error.message}`);
      closeBoth();
    });
    clientSocket.once("error", () => {
      closeBoth();
    });

    upstreamSocket.once("connect", () => {
      upstreamSocket.write(outboundRequest, "latin1");
      if (bodyRemainder.length > 0) {
        upstreamSocket.write(bodyRemainder);
      }

      proxied = true;
      clientSocket.pipe(upstreamSocket);
      upstreamSocket.pipe(clientSocket);
    });
  };

  clientSocket.on("data", onData);
}

function main() {
  let config;
  try {
    config = parseArgs(process.argv.slice(2));
  } catch (error) {
    console.error(`[ws-proxy] ${error.message}`);
    process.exit(2);
    return;
  }

  const server = net.createServer((socket) => onConnection(socket, config));
  server.on("error", (error) => {
    console.error(`[ws-proxy] server error: ${error.message}`);
    process.exitCode = 1;
  });

  server.listen(config.listenPort, config.listenHost, () => {
    console.log(
      `[ws-proxy] listening on ws://${config.listenHost}:${config.listenPort} -> ${config.upstream}`
    );
  });
}

main();

run_app_server_stack.sh

#!/usr/bin/env zsh
set -euo pipefail

script_dir="$(cd "$(dirname "$0")" && pwd)"
project_root="$(cd "${script_dir}/.." && pwd)"

app_listen_host="${APP_SERVER_LISTEN_HOST:-0.0.0.0}"
app_port="${APP_SERVER_PORT:-8080}"
proxy_listen_host="${APP_SERVER_PROXY_LISTEN_HOST:-0.0.0.0}"
proxy_port="${APP_SERVER_PROXY_PORT:-18081}"
proxy_upstream_host="${APP_SERVER_PROXY_UPSTREAM_HOST:-127.0.0.1}"
proxy_upstream_port="${APP_SERVER_PROXY_UPSTREAM_PORT:-${app_port}}"

app_listen_url="ws://${app_listen_host}:${app_port}"
proxy_upstream_url="ws://${proxy_upstream_host}:${proxy_upstream_port}"

logs_dir="${project_root}/.build/logs"
mkdir -p "${logs_dir}"
app_log="${logs_dir}/app-server.log"
proxy_log="${logs_dir}/ws-proxy.log"

require_command() {
  local command_name="$1"
  if ! command -v "${command_name}" >/dev/null 2>&1; then
    echo "Missing required command: ${command_name}" >&2
    exit 1
  fi
}

assert_port_available() {
  local port="$1"
  local label="$2"
  if command -v lsof >/dev/null 2>&1 && lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1; then
    echo "${label} port is already in use: ${port}" >&2
    lsof -nP -iTCP:"${port}" -sTCP:LISTEN >&2 || true
    exit 1
  fi
}

cleanup() {
  local exit_code=$?
  trap - INT TERM EXIT
  if [[ -n "${proxy_pid:-}" ]] && kill -0 "${proxy_pid}" >/dev/null 2>&1; then
    kill "${proxy_pid}" >/dev/null 2>&1 || true
  fi
  if [[ -n "${app_pid:-}" ]] && kill -0 "${app_pid}" >/dev/null 2>&1; then
    kill "${app_pid}" >/dev/null 2>&1 || true
  fi
  wait "${proxy_pid:-}" >/dev/null 2>&1 || true
  wait "${app_pid:-}" >/dev/null 2>&1 || true
  exit "${exit_code}"
}

require_command codex
require_command node

assert_port_available "${app_port}" "App server"
assert_port_available "${proxy_port}" "Proxy"

: > "${app_log}"
: > "${proxy_log}"

codex app-server --listen "${app_listen_url}" >>"${app_log}" 2>&1 &
app_pid=$!

sleep 0.4
if ! kill -0 "${app_pid}" >/dev/null 2>&1; then
  echo "Failed to start codex app-server. See log: ${app_log}" >&2
  tail -n 60 "${app_log}" >&2 || true
  exit 1
fi

node "${script_dir}/ws_strip_extensions_proxy.js" \
  --upstream "${proxy_upstream_url}" \
  --listen-host "${proxy_listen_host}" \
  --listen-port "${proxy_port}" >>"${proxy_log}" 2>&1 &
proxy_pid=$!

sleep 0.4
if ! kill -0 "${proxy_pid}" >/dev/null 2>&1; then
  echo "Failed to start websocket proxy. See log: ${proxy_log}" >&2
  tail -n 60 "${proxy_log}" >&2 || true
  kill "${app_pid}" >/dev/null 2>&1 || true
  wait "${app_pid}" >/dev/null 2>&1 || true
  exit 1
fi

trap cleanup INT TERM EXIT

echo "App stack is running."
echo "  app-server: ${app_listen_url}"
echo "  proxy:      ws://${proxy_listen_host}:${proxy_port} -> ${proxy_upstream_url}"
echo "  iOS URL:    ws://<tailnet-ip>:${proxy_port}"
echo "  logs:       ${app_log}, ${proxy_log}"
echo "Press Ctrl+C to stop both processes."

while true; do
  if ! kill -0 "${app_pid}" >/dev/null 2>&1; then
    echo "codex app-server stopped unexpectedly. See: ${app_log}" >&2
    exit 1
  fi
  if ! kill -0 "${proxy_pid}" >/dev/null 2>&1; then
    echo "ws proxy stopped unexpectedly. See: ${proxy_log}" >&2
    exit 1
  fi
  sleep 1
done
shuto-S · 6 months ago

I closed this thread because I was able to solve it personally, but I'm reopening it because there seems to be demand for it.
I'd be happy if the codex could solve it on its own.

cl1107 · 6 months ago
@cl1107 Thanks for the question — and sorry, I forgot to include the solution earlier. I run Codex app-server behind a small WebSocket proxy: ws_strip_extensions_proxy.js (header-normalizing proxy) run_app_server_stack.sh (starts/monitors app-server + proxy together) This setup avoids connecting React directly to codex app-server and normalizes the handshake request before forwarding upstream. What it covers: Removes Sec-WebSocket-Extensions before forwarding Rewrites Host for the upstream target Preserves client path/query by default (or uses explicit upstream path/query if configured) Starts both processes in order, monitors them, and stops both cleanly on Ctrl+C Splits logs for easier troubleshooting (app-server vs proxy) In short, this mainly mitigates handshake failures caused by extension-header incompatibility (e.g., permessage-deflate negotiation mismatch). It does not by itself solve unrelated issues such as Origin policy, auth headers, or TLS configuration. If you know a better way, I’d love to hear it. ### ws_strip_extensions_proxy.js #!/usr/bin/env node "use strict"; const net = require("node:net"); const tls = require("node:tls"); const MAX_HEADER_BYTES = 64 1024; function usage() { console.log( [ "Usage:", " node scripts/ws_strip_extensions_proxy.js [--upstream ws://127.0.0.1:8080] [--listen-host 0.0.0.0] [--listen-port 18081]", "", "Options:", " --upstream Upstream codex app-server URL (ws:// or wss://)", " --listen-host Local bind host (default: 0.0.0.0)", " --listen-port Local bind port (default: 18081)", " --help Show this help", ].join("\n") ); } function parseArgs(argv) { const result = { upstream: "ws://127.0.0.1:8080", listenHost: "0.0.0.0", listenPort: 18081, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--help" || arg === "-h") { usage(); process.exit(0); } if (arg === "--upstream") { result.upstream = argv[index + 1] ?? ""; index += 1; continue; } if (arg === "--listen-host") { result.listenHost = argv[index + 1] ?? ""; index += 1; continue; } if (arg === "--listen-port") { result.listenPort = Number(argv[index + 1]); index += 1; continue; } console.error(Unknown argument: ${arg}); usage(); process.exit(2); } if (!Number.isInteger(result.listenPort) || result.listenPort < 1 || result.listenPort > 65535) { throw new Error("listen-port must be an integer between 1 and 65535."); } let upstreamURL; try { upstreamURL = new URL(result.upstream); } catch { throw new Error(Invalid upstream URL: ${result.upstream}); } if (upstreamURL.protocol !== "ws:" && upstreamURL.protocol !== "wss:") { throw new Error("upstream URL must use ws:// or wss://"); } result.upstreamURL = upstreamURL; return result; } function respondAndClose(socket, statusLine) { if (socket.destroyed) { return; } socket.end(${statusLine}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n); } function parseRequest(rawHeaders) { const lines = rawHeaders.split("\r\n"); const requestLine = lines.shift() ?? ""; const [method, path, version] = requestLine.split(" "); if (method !== "GET" || !path || version !== "HTTP/1.1") { return null; } const headers = []; for (const line of lines) { if (!line) { continue; } const separator = line.indexOf(":"); if (separator === -1) { continue; } const name = line.slice(0, separator).trim(); const value = line.slice(separator + 1).trim(); headers.push([name, value]); } return { path, headers }; } function selectedUpstreamPath(upstreamURL, clientPath) { const hasCustomPath = upstreamURL.pathname && upstreamURL.pathname !== "/"; const hasCustomQuery = upstreamURL.search && upstreamURL.search.length > 0; if (hasCustomPath || hasCustomQuery) { return ${upstreamURL.pathname || "/"}${upstreamURL.search || ""}; } return clientPath; } function connectUpstream(upstreamURL) { const port = upstreamURL.port !== "" ? Number(upstreamURL.port) : upstreamURL.protocol === "wss:" ? 443 : 80; if (upstreamURL.protocol === "wss:") { return tls.connect({ host: upstreamURL.hostname, port, servername: upstreamURL.hostname, }); } return net.connect({ host: upstreamURL.hostname, port, }); } function onConnection(clientSocket, config) { const remote = ${clientSocket.remoteAddress ?? "unknown"}:${clientSocket.remotePort ?? 0}; let buffered = Buffer.alloc(0); let proxied = false; const onData = (chunk) => { if (proxied) { return; } buffered = Buffer.concat([buffered, chunk]); if (buffered.length > MAX_HEADER_BYTES) { console.warn([ws-proxy] dropped ${remote}: request headers too large); respondAndClose(clientSocket, "HTTP/1.1 431 Request Header Fields Too Large"); return; } const markerIndex = buffered.indexOf("\r\n\r\n"); if (markerIndex === -1) { return; } clientSocket.removeListener("data", onData); const headerRaw = buffered.subarray(0, markerIndex).toString("latin1"); const bodyRemainder = buffered.subarray(markerIndex + 4); const request = parseRequest(headerRaw); if (!request) { respondAndClose(clientSocket, "HTTP/1.1 400 Bad Request"); return; } const outboundHeaders = []; for (const [name, value] of request.headers) { const lower = name.toLowerCase(); if (lower === "host") { continue; } if (lower === "sec-websocket-extensions") { continue; } outboundHeaders.push([name, value]); } outboundHeaders.push(["Host", config.upstreamURL.host]); if (clientSocket.remoteAddress) { outboundHeaders.push(["X-Forwarded-For", clientSocket.remoteAddress]); } const outboundPath = selectedUpstreamPath(config.upstreamURL, request.path); const outboundRequest = [ GET ${outboundPath} HTTP/1.1, ...outboundHeaders.map(([name, value]) => ${name}: ${value}), "", "", ].join("\r\n"); const upstreamSocket = connectUpstream(config.upstreamURL); const closeBoth = () => { if (!clientSocket.destroyed) { clientSocket.destroy(); } if (!upstreamSocket.destroyed) { upstreamSocket.destroy(); } }; upstreamSocket.once("error", (error) => { console.warn([ws-proxy] upstream error for ${remote}: ${error.message}); closeBoth(); }); clientSocket.once("error", () => { closeBoth(); }); upstreamSocket.once("connect", () => { upstreamSocket.write(outboundRequest, "latin1"); if (bodyRemainder.length > 0) { upstreamSocket.write(bodyRemainder); } proxied = true; clientSocket.pipe(upstreamSocket); upstreamSocket.pipe(clientSocket); }); }; clientSocket.on("data", onData); } function main() { let config; try { config = parseArgs(process.argv.slice(2)); } catch (error) { console.error([ws-proxy] ${error.message}); process.exit(2); return; } const server = net.createServer((socket) => onConnection(socket, config)); server.on("error", (error) => { console.error([ws-proxy] server error: ${error.message}); process.exitCode = 1; }); server.listen(config.listenPort, config.listenHost, () => { console.log( [ws-proxy] listening on ws://${config.listenHost}:${config.listenPort} -> ${config.upstream} ); }); } main(); ### run_app_server_stack.sh #!/usr/bin/env zsh set -euo pipefail script_dir="$(cd "$(dirname "$0")" && pwd)" project_root="$(cd "${script_dir}/.." && pwd)" app_listen_host="${APP_SERVER_LISTEN_HOST:-0.0.0.0}" app_port="${APP_SERVER_PORT:-8080}" proxy_listen_host="${APP_SERVER_PROXY_LISTEN_HOST:-0.0.0.0}" proxy_port="${APP_SERVER_PROXY_PORT:-18081}" proxy_upstream_host="${APP_SERVER_PROXY_UPSTREAM_HOST:-127.0.0.1}" proxy_upstream_port="${APP_SERVER_PROXY_UPSTREAM_PORT:-${app_port}}" app_listen_url="ws://${app_listen_host}:${app_port}" proxy_upstream_url="ws://${proxy_upstream_host}:${proxy_upstream_port}" logs_dir="${project_root}/.build/logs" mkdir -p "${logs_dir}" app_log="${logs_dir}/app-server.log" proxy_log="${logs_dir}/ws-proxy.log" require_command() { local command_name="$1" if ! command -v "${command_name}" >/dev/null 2>&1; then echo "Missing required command: ${command_name}" >&2 exit 1 fi } assert_port_available() { local port="$1" local label="$2" if command -v lsof >/dev/null 2>&1 && lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1; then echo "${label} port is already in use: ${port}" >&2 lsof -nP -iTCP:"${port}" -sTCP:LISTEN >&2 || true exit 1 fi } cleanup() { local exit_code=$? trap - INT TERM EXIT if [[ -n "${proxy_pid:-}" ]] && kill -0 "${proxy_pid}" >/dev/null 2>&1; then kill "${proxy_pid}" >/dev/null 2>&1 || true fi if [[ -n "${app_pid:-}" ]] && kill -0 "${app_pid}" >/dev/null 2>&1; then kill "${app_pid}" >/dev/null 2>&1 || true fi wait "${proxy_pid:-}" >/dev/null 2>&1 || true wait "${app_pid:-}" >/dev/null 2>&1 || true exit "${exit_code}" } require_command codex require_command node assert_port_available "${app_port}" "App server" assert_port_available "${proxy_port}" "Proxy" : > "${app_log}" : > "${proxy_log}" codex app-server --listen "${app_listen_url}" >>"${app_log}" 2>&1 & app_pid=$! sleep 0.4 if ! kill -0 "${app_pid}" >/dev/null 2>&1; then echo "Failed to start codex app-server. See log: ${app_log}" >&2 tail -n 60 "${app_log}" >&2 || true exit 1 fi node "${script_dir}/ws_strip_extensions_proxy.js" \ --upstream "${proxy_upstream_url}" \ --listen-host "${proxy_listen_host}" \ --listen-port "${proxy_port}" >>"${proxy_log}" 2>&1 & proxy_pid=$! sleep 0.4 if ! kill -0 "${proxy_pid}" >/dev/null 2>&1; then echo "Failed to start websocket proxy. See log: ${proxy_log}" >&2 tail -n 60 "${proxy_log}" >&2 || true kill "${app_pid}" >/dev/null 2>&1 || true wait "${app_pid}" >/dev/null 2>&1 || true exit 1 fi trap cleanup INT TERM EXIT echo "App stack is running." echo " app-server: ${app_listen_url}" echo " proxy: ws://${proxy_listen_host}:${proxy_port} -> ${proxy_upstream_url}" echo " iOS URL: ws://<tailnet-ip>:${proxy_port}" echo " logs: ${app_log}, ${proxy_log}" echo "Press Ctrl+C to stop both processes." while true; do if ! kill -0 "${app_pid}" >/dev/null 2>&1; then echo "codex app-server stopped unexpectedly. See: ${app_log}" >&2 exit 1 fi if ! kill -0 "${proxy_pid}" >/dev/null 2>&1; then echo "ws proxy stopped unexpectedly. See: ${proxy_log}" >&2 exit 1 fi sleep 1 done

Thanks a lot for sharing this — this is very helpful.

Before your reply, codex had implemented a simpler WS relay version:

  • Node.js ws server that accepts client connections
  • Opens an upstream ws://... connection to codex app-server
  • Sets perMessageDeflate: false on the upstream side
  • Bi-directionally forwards frames (client <-> upstream)

That version works for quick validation, but it does not explicitly normalize handshake headers at the raw HTTP level.
I think Your version is better
hope codex can solve it on its own

sstklen · 6 months ago

Hey! I ran into a similar pattern in our bug knowledge base and thought this might help.

What's happening: codex app-server's WebSocket upgrade response includes a Sec-WebSocket-Extensions: permessage-deflate header that iOS URLSessionWebSocketTask cannot negotiate properly — iOS interprets the mismatch as a broken connection and fires -1005 before any data frame is exchanged

What worked for us:

Run a thin WebSocket proxy (Node.js ws or http-proxy) between iOS and codex app-server that intercepts the HTTP 101 upgrade response and strips the Sec-WebSocket-Extensions header. Alternatively, if you control the server config, disable permessage-deflate on the server side. For React Native (Expo), the same fix applies since Expo's WebSocket also delegates to URLSessionWebSocketTask on iOS.

// ws_strip_extensions_proxy.js
const http = require('http');
const net = require('net');

const UPSTREAM = { host: '127.0.0.1', port: 8080 };
const LISTEN_PORT = 8081;

const server = http.createServer((req, res) => res.writeHead(426).end());

server.on('upgrade', (req, socket, head) => {
  const upstream = net.createConnection(UPSTREAM.port, UPSTREAM.host, () => {
    let rawReq = `${req.method} ${req.url} HTTP/1.1\r\n`;
    for (let i = 0; i < req.rawHeaders.length; i += 2) {
      // Strip client-side extensions offer so server won't negotiate it
      if (req.rawHeaders[i].toLowerCase() === 'sec-websocket-extensions') continue;
      rawReq += `${req.rawHeaders[i]}: ${req.rawHeaders[i+1]}\r\n`;
    }
    rawReq += '\r\n';
    upstream.write(rawReq);
    if (head.length) upstream.write(head);
  });

  let firstChunk = true;
  upstream.on('data', (chunk) => {
    if (firstChunk) {
      firstChunk = false;
      // Also strip extensions from server 101 response
      const resp = chunk.toString();
      const headerEnd = resp.indexOf('\r\n\r\n');
      if (headerEnd !== -1) {
        const filtered = resp.slice(0, headerEnd)
          .split('\r\n')
          .filter(l => !l.toLowerCase().startsWith('sec-websocket-extensions'))
          .join('\r\n') + '\r\n\r\n' + resp.slice(headerEnd + 4);
        socket.write(filtered);
        return;
      }
    }
    socket.write(chunk);
  });

  socket.on('data', (chunk) => upstream.write(chunk));
  socket.on('end', () => upstream.end());
  upstream.on('end', () => socket.end());
  socket.on('error', () => upstream.destroy());
  upstream.on('error', () => socket.destroy());
});

server.listen(LISTEN_PORT, '0.0.0.0', () => {
  console.log(`WS proxy :${LISTEN_PORT} -> :${UPSTREAM.port} (stripping extensions)`);
});

Hope this helps! Let me know if it doesn't match your case — happy to dig deeper. 🦞

_Disclosure: This analysis is from Confucius Debug, an AI-powered community KB for agent bugs. Please verify before applying._

---
<sub>🦞 Confucius Debug — community knowledge base for AI agent bugs. Free to search via MCP.</sub>