Skip to content

codex app-server proxy hangs on WebSocket-framed initialize while direct Unix socket succeeds #25846

Open

Description

@bakemocho

Summary

codex app-server proxy --sock <default control socket> appears not to forward a WebSocket-framed initialize exchange even though the same WebSocket handshake and masked text frame works directly against the Unix control socket.

This is related to #23572, but this reproduction does not use Content-Length framing. It uses the framing described in codex-rs/app-server/README.md: WebSocket HTTP Upgrade followed by WebSocket frames.

Environment

  • Platform: macOS / Darwin arm64
  • Codex Desktop: 26.527.60818 / build 3437
  • Desktop-bundled codex: codex-cli 0.136.0-alpha.2
  • Standalone current codex: codex-cli 0.135.0
  • codex app-server daemon version:
    • status: running
    • backend: pid
    • managedCodexVersion: 0.135.0
    • cliVersion: 0.136.0-alpha.2
    • appServerVersion: 0.135.0

What I tested

I sent the same minimal app-server initialize request over three transports:

  1. Raw Unix socket connection to the default app-server control socket.
  2. ~/.codex/packages/standalone/current/codex app-server proxy --sock <default control socket>.
  3. Desktop-bundled codex app-server proxy --sock <default control socket>.

For all three, the client input was:

  • HTTP WebSocket Upgrade request.
  • Client-masked WebSocket text frame containing:
{"id":1,"method":"initialize","params":{"clientInfo":{"name":"proxy-repro","version":"0.0.0"},"capabilities":{"experimentalApi":true,"requestAttestation":false}}}

Activity

  1. added
    bugSomething isn't working
    CLIIssues related to the Codex CLI
    app-serverIssues involving app server protocol or interfaces
    connectivityIssues involving networking or endpoint connectivity problems (disconnections)
    on Jun 2, 2026
  2. bakemocho commented on Jun 4, 2026

    @bakemocho
    Author

    I reduced the proxy behavior reported here to a small no-dependency Node.js
    reproducer that sends only initialize over the Unix-socket WebSocket framing
    described in the app-server README. It compares the same request bytes through:

    1. a direct connection to the app-server control socket;
    2. codex app-server proxy --sock <same socket>;
    3. $HOME/.codex/packages/standalone/current/codex app-server proxy --sock <same socket>, if that binary exists.

    The script deletes common OpenAI/Codex API environment variables for proxy child
    processes and prints only a summary. It does not call thread, turn, model,
    provider, filesystem, or remote-control APIs.

    Run:

    node codex-app-server-proxy-websocket-initialize-comment-repro.mjs

    Optional:

    TIMEOUT_MS=10000 node codex-app-server-proxy-websocket-initialize-comment-repro.mjs /path/to/app-server-control.sock

    The output shape that would confirm the proxy-specific failure is:

    raw unix socket:
      handshake_observed=true
      switching_protocols=true
      initialize_ok=true
    
    codex app-server proxy:
      initialize_ok=false
      completion_reason=timeout or no initialize response
    
    Standalone Node.js reproducer
    #!/usr/bin/env node
    
    import crypto from "node:crypto";
    import fs from "node:fs";
    import net from "node:net";
    import os from "node:os";
    import path from "node:path";
    import { spawn } from "node:child_process";
    
    const socketPath = process.argv[2] || path.join(os.homedir(), ".codex", "app-server-control", "app-server-control.sock");
    const timeoutMs = Number(process.env.TIMEOUT_MS || 6000);
    const apiEnvKeys = ["OPENAI_API_KEY", "CODEX_API_KEY", "OPENAI_BASE_URL", "OPENAI_ORG_ID", "OPENAI_PROJECT_ID"];
    
    function usage() {
      console.error(`Usage:
      node codex-app-server-proxy-websocket-initialize-comment-repro.mjs [socket-path]
    
    Optional:
      TIMEOUT_MS=6000
    
    This sends only app-server initialize over WebSocket HTTP Upgrade plus a masked
    text frame. It does not call thread, turn, model, provider, or filesystem APIs.`);
    }
    
    function appServerEnv() {
      const env = { ...process.env };
      for (const key of apiEnvKeys) delete env[key];
      return env;
    }
    
    function initializeRequest() {
      return {
        id: 1,
        method: "initialize",
        params: {
          clientInfo: {
            name: "proxy_initialize_repro",
            title: "Proxy Initialize Repro",
            version: "0.1.0",
          },
          capabilities: {
            experimentalApi: true,
            requestAttestation: false,
          },
        },
      };
    }
    
    function websocketHandshakeBytes() {
      return Buffer.from(
        [
          "GET / HTTP/1.1",
          "Host: localhost",
          "Upgrade: websocket",
          "Connection: Upgrade",
          `Sec-WebSocket-Key: ${crypto.randomBytes(16).toString("base64")}`,
          "Sec-WebSocket-Version: 13",
          "",
          "",
        ].join("\r\n"),
        "utf8",
      );
    }
    
    function maskedTextFrame(text) {
      const payload = Buffer.from(text, "utf8");
      const mask = crypto.randomBytes(4);
      const header = payload.length < 126
        ? Buffer.from([0x81, 0x80 | payload.length])
        : Buffer.from([0x81, 0x80 | 126, (payload.length >> 8) & 0xff, payload.length & 0xff]);
      const masked = Buffer.alloc(payload.length);
      for (let index = 0; index < payload.length; index += 1) {
        masked[index] = payload[index] ^ mask[index % 4];
      }
      return Buffer.concat([header, mask, masked]);
    }
    
    function requestBytes() {
      return Buffer.concat([
        websocketHandshakeBytes(),
        maskedTextFrame(JSON.stringify(initializeRequest())),
      ]);
    }
    
    function parseFirstTextFrame(buffer) {
      if (buffer.length < 2) return null;
      let offset = 0;
      const first = buffer[offset++];
      const second = buffer[offset++];
      const opcode = first & 0x0f;
      const masked = (second & 0x80) !== 0;
      let length = second & 0x7f;
      if (length === 126) {
        if (buffer.length < offset + 2) return null;
        length = buffer.readUInt16BE(offset);
        offset += 2;
      } else if (length === 127) {
        return { opcode, error: "unsupported_64_bit_frame_length" };
      }
      let mask = null;
      if (masked) {
        if (buffer.length < offset + 4) return null;
        mask = buffer.subarray(offset, offset + 4);
        offset += 4;
      }
      if (buffer.length < offset + length) return null;
      const payload = Buffer.from(buffer.subarray(offset, offset + length));
      if (mask) {
        for (let index = 0; index < payload.length; index += 1) {
          payload[index] ^= mask[index % 4];
        }
      }
      return { opcode, payload: payload.toString("utf8") };
    }
    
    function parseResponse(buffer) {
      const text = buffer.toString("binary");
      const headerEnd = text.indexOf("\r\n\r\n");
      if (headerEnd < 0) return { handshake: false, statusLine: null, json: null };
      const statusLine = Buffer.from(text.slice(0, headerEnd), "binary").toString("utf8").split("\r\n")[0];
      const frame = parseFirstTextFrame(buffer.subarray(headerEnd + 4));
      let json = null;
      try {
        json = frame?.payload ? JSON.parse(frame.payload) : null;
      } catch {
        json = null;
      }
      return { handshake: true, statusLine, json };
    }
    
    function summarize(label, reason, chunks, stderr = "", error = null) {
      const parsed = parseResponse(Buffer.concat(chunks));
      const result = parsed.json?.result || null;
      return {
        label,
        completion_reason: reason,
        sent_methods: ["initialize"],
        handshake_observed: parsed.handshake,
        switching_protocols: /^HTTP\/1\.[01] 101\b/.test(parsed.statusLine || ""),
        initialize_ok: parsed.json?.id === 1 && Boolean(result),
        result_keys: result ? Object.keys(result).sort() : [],
        stderr_observed: Boolean(stderr),
        stderr_excerpt: stderr.slice(0, 300),
        error,
      };
    }
    
    async function rawSocketProbe() {
      return await new Promise((resolve) => {
        const socket = net.createConnection(socketPath);
        const chunks = [];
        let done = false;
        const timer = setTimeout(() => finish("timeout"), timeoutMs);
        function finish(reason, error = null) {
          if (done) return;
          done = true;
          clearTimeout(timer);
          socket.destroy();
          resolve(summarize("raw unix socket", reason, chunks, "", error));
        }
        socket.on("connect", () => socket.write(requestBytes()));
        socket.on("data", (chunk) => {
          chunks.push(chunk);
          if (parseResponse(Buffer.concat(chunks)).json?.id === 1) finish("initialize_response_observed");
        });
        socket.on("error", (error) => finish("socket_error", error.message));
        socket.on("end", () => finish("socket_end"));
      });
    }
    
    async function proxyProbe(command, label) {
      return await new Promise((resolve) => {
        const child = spawn(command, ["app-server", "proxy", "--sock", socketPath], {
          env: appServerEnv(),
          stdio: ["pipe", "pipe", "pipe"],
        });
        const chunks = [];
        let stderr = "";
        let done = false;
        const timer = setTimeout(() => finish("timeout"), timeoutMs);
        function finish(reason) {
          if (done) return;
          done = true;
          clearTimeout(timer);
          child.kill("SIGTERM");
          resolve(summarize(label, reason, chunks, stderr));
        }
        child.stdout.on("data", (chunk) => {
          chunks.push(chunk);
          if (parseResponse(Buffer.concat(chunks)).json?.id === 1) finish("initialize_response_observed");
        });
        child.stderr.on("data", (chunk) => {
          stderr += chunk.toString("utf8");
        });
        child.on("error", (error) => {
          stderr += error.message;
          finish("process_error");
        });
        child.on("exit", () => finish("process_exit"));
        child.stdin.on("error", () => {});
        child.stdin.write(requestBytes());
      });
    }
    
    async function main() {
      if (process.argv.includes("--help") || process.argv.includes("-h")) {
        usage();
        return;
      }
      const standalone = path.join(os.homedir(), ".codex", "packages", "standalone", "current", "codex");
      const results = [await rawSocketProbe(), await proxyProbe("codex", "codex app-server proxy")];
      if (fs.existsSync(standalone)) {
        results.push(await proxyProbe(standalone, "$HOME/.codex/packages/standalone/current/codex app-server proxy"));
      }
      console.log(JSON.stringify({
        socket_path_public: false,
        timeout_ms: timeoutMs,
        results,
      }, null, 2));
    }
    
    main().catch((error) => {
      console.error(error.message || error);
      usage();
      process.exitCode = 1;
    });
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

    CLIIssues related to the Codex CLIapp-serverIssues involving app server protocol or interfacesbugSomething isn't workingconnectivityIssues involving networking or endpoint connectivity problems (disconnections)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions