From 48fbcc58b579874631d64b62680ee9c92e9a9601 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 21:37:40 +0000 Subject: [PATCH 1/2] Fix desktop-shim fail-open, self-fallback loop, bridge gating, and post-upgrade hang - Bridge commits on first stdout frame as well as stdin bytes, so a greet-then-close daemon exits mid-session (2) instead of falling through to real Codex on dirty stdout (1). - Wrapper refuses a command -v codex fallback that resolves to itself ($0 / CODEX_CLI_PATH) instead of exec-looping; fails with no runnable codex. - Bridge attempt gated on BRIDGE + python3 + preflight only; REAL only gates passthrough/fallthrough. - Post-upgrade socket I/O bounded by CODEX_BRIDGE_IO_TIMEOUT (30s); timeouts exit via the commit-aware mid-session path. - uninstall only reports paths that existed before delete; Linux status no longer says relaunch ChatGPT.app. Co-authored-by: Zack Jackson --- src/core/desktop-shim-bridge.js | 11 +- src/core/desktop-shim.js | 18 ++- src/core/format.js | 6 +- test/desktop-shim.test.js | 225 +++++++++++++++++++++++++++++++- 4 files changed, 248 insertions(+), 12 deletions(-) diff --git a/src/core/desktop-shim-bridge.js b/src/core/desktop-shim-bridge.js index 8be5823..5004fea 100644 --- a/src/core/desktop-shim-bridge.js +++ b/src/core/desktop-shim-bridge.js @@ -13,10 +13,13 @@ // daemon-lock hang. Exits are 0 served, 1 pre-stdio (wrapper falls back to // real Codex), 2 mid-session (wrapper exits so Desktop reconnects). // (4) Byte-precise commit: os.read into a userspace line buffer counts every -// stdin byte (buffered readline could readahead past the commit point), so the -// fallback only ever runs on a truly pristine stdio. The reader signals a done -// event so WS close wakes the select loop; no path leaves a thread hung. +// stdin byte (buffered readline could readahead past the commit point), and the +// first stdout frame commits too, so the fallback only ever runs on truly +// pristine stdio. Post-upgrade I/O runs under CODEX_BRIDGE_IO_TIMEOUT +// (default 30s) so a wedged daemon cannot hang Desktop in send/recv. The +// reader signals a done event so WS close wakes the select loop; no path +// leaves a thread hung. // Desktop stdio hangs with stock `codex app-server proxy`, so this custom // bridge is required. Install writes it to the CODEX_HOME bin dir. Never reads // Desktop private pipes (CODEX_APP_TOOLS_PIPE_PATH, /tmp/codex-browser-use/). -export const BRIDGE_SOURCE = "#!/usr/bin/env python3\n\"\"\"Stdio JSONL (Desktop) <-> WebSocket-over-unix (managed app-server daemon).\nReversible: remove CODEX_CLI_PATH wrapper. Does not read Desktop private pipes.\n\"\"\"\nfrom __future__ import annotations\nimport base64, hashlib, json, os, select, socket, struct, sys, threading, time\n\nSOCK = os.environ.get(\n \"CODEX_APP_SERVER_SOCK\",\n os.path.expanduser(\"~/.codex/app-server-control/app-server-control.sock\"),\n)\nLOG = os.environ.get(\"CODEX_STDIO_BRIDGE_LOG\", os.path.expanduser(\"~/.codex/bin/codex-stdio-to-daemon-ws.log\"))\nWS_GUID = \"258EAFA5-E914-47DA-95CA-C5AB0DC85B11\"\n\n# Wrapper contract: 0 served the session, 1 failed before any stdin was\n# consumed (wrapper falls back to real Codex on pristine stdio), 2 failed\n# mid-session (wrapper exits promptly so Desktop reconnects; the fallback\n# must never run on half-consumed stdin).\nEXIT_SERVED = 0\nEXIT_PRE_STDIO = 1\nEXIT_MID_SESSION = 2\n\nCONNECT_TIMEOUT_S = float(os.environ.get(\"CODEX_BRIDGE_CONNECT_TIMEOUT\", \"10\"))\n# Bound from connect to the first daemon message: a daemon that completes the\n# upgrade but never answers the first RPC must not hold Desktop past this.\nFIRST_MESSAGE_TIMEOUT_S = float(os.environ.get(\"CODEX_BRIDGE_FIRST_MESSAGE_TIMEOUT\", \"30\"))\n\ndef log(msg: str) -> None:\n try:\n with open(LOG, \"a\") as f:\n f.write(time.strftime(\"%Y-%m-%dT%H:%M:%SZ\", time.gmtime()) + \" \" + msg + \"\\n\")\n except OSError:\n pass\n\ndef ws_connect(path: str, timeout: float = CONNECT_TIMEOUT_S):\n \"\"\"Connect + HTTP upgrade under one absolute monotonic deadline.\n\n Returns (socket, trailing) where trailing holds bytes coalesced after the\n upgrade headers (TCP may deliver the 101 and the first WS frame together;\n discarding them would lose the first message). Validates the 101 status\n and Sec-WebSocket-Accept so a non-WebSocket listener fails fast.\n \"\"\"\n deadline = time.monotonic() + timeout\n s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)\n s.settimeout(timeout)\n try:\n s.connect(path)\n key = base64.b64encode(os.urandom(16)).decode()\n expected = base64.b64encode(hashlib.sha1((key + WS_GUID).encode()).digest()).decode()\n req = (\n f\"GET /rpc HTTP/1.1\\r\\nHost: localhost\\r\\nUpgrade: websocket\\r\\n\"\n f\"Connection: Upgrade\\r\\nSec-WebSocket-Key: {key}\\r\\nSec-WebSocket-Version: 13\\r\\n\\r\\n\"\n ).encode()\n s.sendall(req)\n buf = b\"\"\n while True:\n if b\"\\r\\n\\r\\n\" in buf:\n break\n remaining = deadline - time.monotonic()\n if remaining <= 0:\n raise TimeoutError(\"WebSocket upgrade timed out\")\n if len(buf) > 65536:\n raise ConnectionError(\"WS upgrade headers too large\")\n s.settimeout(remaining)\n chunk = s.recv(4096)\n if not chunk:\n raise ConnectionError(\"daemon closed during WS upgrade\")\n buf += chunk\n head, _, trailing = buf.partition(b\"\\r\\n\\r\\n\")\n lines = head.split(b\"\\r\\n\")\n if b\"101\" not in lines[0]:\n raise ConnectionError(f\"WS upgrade failed: {head[:200]!r}\")\n accept = None\n for line in lines[1:]:\n name, sep, value = line.partition(b\":\")\n if sep and name.strip().lower() == b\"sec-websocket-accept\":\n accept = value.strip().decode(\"latin-1\")\n if accept != expected:\n raise ConnectionError(\"WS upgrade accept mismatch\")\n except Exception:\n try:\n s.close()\n except OSError:\n pass\n raise\n s.settimeout(None)\n return s, trailing\n\ndef mask_send(sock: socket.socket, payload: bytes, opcode: int = 1) -> None:\n mask = os.urandom(4)\n ln = len(payload)\n if ln < 126:\n hdr = bytes([0x80 | opcode, 0x80 | ln])\n elif ln < 65536:\n hdr = bytes([0x80 | opcode, 0x80 | 126]) + struct.pack(\"!H\", ln)\n else:\n hdr = bytes([0x80 | opcode, 0x80 | 127]) + struct.pack(\"!Q\", ln)\n sock.sendall(hdr + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(payload)))\n\ndef locked_send(sock: socket.socket, lock: threading.Lock, payload: bytes, opcode: int = 1) -> None:\n with lock:\n mask_send(sock, payload, opcode=opcode)\n\ndef _buffered_reader(sock: socket.socket, initial: bytes = b\"\"):\n buf = bytearray(initial)\n def recv(n):\n while len(buf) < n:\n chunk = sock.recv(n - len(buf))\n if not chunk:\n raise ConnectionError(\"eof\")\n buf.extend(chunk)\n out = bytes(buf[:n])\n del buf[:n]\n return out\n return recv\n\ndef read_frame(recv):\n hdr = recv(2)\n fin = bool(hdr[0] & 0x80)\n opcode = hdr[0] & 0x0F\n masked = bool(hdr[1] & 0x80)\n ln = hdr[1] & 0x7F\n if ln == 126:\n ln = struct.unpack(\"!H\", recv(2))[0]\n elif ln == 127:\n ln = struct.unpack(\"!Q\", recv(8))[0]\n mask = recv(4) if masked else b\"\"\n payload = recv(ln)\n if masked:\n payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))\n return fin, opcode, payload\n\ndef read_message(recv, send_pong):\n \"\"\"Next complete data message, assembling continuation frames.\n\n Control frames are handled inline (pong answered, close reported) so a\n fragmented message split around a ping still reassembles.\n \"\"\"\n opcode = None\n parts = []\n while True:\n fin, op, payload = read_frame(recv)\n if op == 0x8:\n return (\"close\", None, b\"\")\n if op == 0x9:\n send_pong(payload)\n continue\n if op == 0xA:\n continue\n if op in (0x1, 0x2):\n if opcode is not None:\n raise ConnectionError(\"data frame inside fragmented message\")\n if fin:\n return (\"data\", op, payload)\n opcode = op\n parts.append(payload)\n continue\n if op == 0x0:\n if opcode is None:\n raise ConnectionError(\"stray continuation frame\")\n parts.append(payload)\n if fin:\n return (\"data\", opcode, b\"\".join(parts))\n continue\n raise ConnectionError(f\"unsupported opcode {op}\")\n\ndef stdout_writer(sock: socket.socket, recv, send_pong, done: threading.Event, first_msg: threading.Event) -> None:\n out = sys.stdout.buffer\n try:\n while True:\n kind, opcode, payload = read_message(recv, send_pong)\n if kind == \"close\":\n log(\"daemon WS close\")\n return\n if opcode == 0x1:\n # Desktop StdioConnection expects newline-delimited JSON text\n if not payload.endswith(b\"\\n\"):\n payload += b\"\\n\"\n out.write(payload)\n out.flush()\n else:\n out.write(payload)\n out.flush()\n first_msg.set()\n except Exception as e:\n log(f\"stdout_writer exit: {e}\")\n finally:\n done.set()\n\ndef main() -> int:\n log(f\"start sock={SOCK}\")\n try:\n sock, pending = ws_connect(SOCK)\n except Exception as e:\n log(f\"connect failed: {e}\")\n sys.stderr.write(f\"codex-stdio-to-daemon-ws: {e}\\n\")\n return EXIT_PRE_STDIO\n log(\"connected\")\n connected_at = time.monotonic()\n send_lock = threading.Lock()\n def send_pong(payload: bytes) -> None:\n locked_send(sock, send_lock, payload, opcode=0xA)\n recv = _buffered_reader(sock, pending)\n done = threading.Event()\n first_msg = threading.Event()\n t = threading.Thread(target=stdout_writer, args=(sock, recv, send_pong, done, first_msg), daemon=True)\n t.start()\n # Byte-precise commit point: the fallback may run only while zero stdin\n # bytes have left the pipe. Reads use os.read into a userspace line buffer\n # (buffered readline could readahead past the commit point), and every\n # byte read counts — even a blank line. first_msg bounds the first RPC.\n stdin_bytes = 0\n pending_in = b\"\"\n clean_eof = False\n try:\n fd = sys.stdin.fileno()\n while not done.is_set():\n if not first_msg.is_set() and time.monotonic() - connected_at > FIRST_MESSAGE_TIMEOUT_S:\n log(\"first daemon message timed out\")\n break\n ready, _, _ = select.select([fd], [], [], 0.5)\n if not ready:\n continue\n chunk = os.read(fd, 65536)\n if not chunk:\n clean_eof = True\n break\n stdin_bytes += len(chunk)\n pending_in += chunk\n while b\"\\n\" in pending_in:\n line, _, pending_in = pending_in.partition(b\"\\n\")\n line = line.strip()\n if not line:\n continue\n locked_send(sock, send_lock, line)\n except Exception as e:\n log(f\"stdin loop exit: {e}\")\n try:\n locked_send(sock, send_lock, b\"\", opcode=0x8)\n except Exception:\n pass\n if clean_eof:\n tail = pending_in.strip()\n if tail and not done.is_set():\n try:\n locked_send(sock, send_lock, tail)\n except Exception as e:\n log(f\"eof flush failed: {e}\")\n log(\"exit\")\n return EXIT_SERVED\n # Reader died, first RPC timed out, or stdin broke: fail open only if no\n # stdin byte was ever consumed.\n rc = EXIT_MID_SESSION if stdin_bytes else EXIT_PRE_STDIO\n log(f\"exit rc={rc}\")\n return rc\n\nif __name__ == \"__main__\":\n raise SystemExit(main())\n"; +export const BRIDGE_SOURCE = "#!/usr/bin/env python3\n\"\"\"Stdio JSONL (Desktop) <-> WebSocket-over-unix (managed app-server daemon).\nReversible: remove CODEX_CLI_PATH wrapper. Does not read Desktop private pipes.\n\"\"\"\nfrom __future__ import annotations\nimport base64, hashlib, json, os, select, socket, struct, sys, threading, time\n\nSOCK = os.environ.get(\n \"CODEX_APP_SERVER_SOCK\",\n os.path.expanduser(\"~/.codex/app-server-control/app-server-control.sock\"),\n)\nLOG = os.environ.get(\"CODEX_STDIO_BRIDGE_LOG\", os.path.expanduser(\"~/.codex/bin/codex-stdio-to-daemon-ws.log\"))\nWS_GUID = \"258EAFA5-E914-47DA-95CA-C5AB0DC85B11\"\n\n# Wrapper contract: 0 served the session, 1 failed while stdio was still\n# pristine — no stdin byte consumed and no stdout frame written (wrapper falls\n# back to real Codex), 2 failed mid-session (wrapper exits promptly so Desktop\n# reconnects; the fallback must never run on half-consumed stdin or dirty stdout).\nEXIT_SERVED = 0\nEXIT_PRE_STDIO = 1\nEXIT_MID_SESSION = 2\n\nCONNECT_TIMEOUT_S = float(os.environ.get(\"CODEX_BRIDGE_CONNECT_TIMEOUT\", \"10\"))\n# Bound from connect to the first daemon message: a daemon that completes the\n# upgrade but never answers the first RPC must not hold Desktop past this.\nFIRST_MESSAGE_TIMEOUT_S = float(os.environ.get(\"CODEX_BRIDGE_FIRST_MESSAGE_TIMEOUT\", \"30\"))\n# Bound on every post-upgrade send/recv: a wedged daemon must not hold Desktop\n# past this. Timeouts exit mid-session when stdio is committed (see exit rc).\nIO_TIMEOUT_S = float(os.environ.get(\"CODEX_BRIDGE_IO_TIMEOUT\", \"30\"))\n\ndef log(msg: str) -> None:\n try:\n with open(LOG, \"a\") as f:\n f.write(time.strftime(\"%Y-%m-%dT%H:%M:%SZ\", time.gmtime()) + \" \" + msg + \"\\n\")\n except OSError:\n pass\n\ndef ws_connect(path: str, timeout: float = CONNECT_TIMEOUT_S):\n \"\"\"Connect + HTTP upgrade under one absolute monotonic deadline.\n\n Returns (socket, trailing) where trailing holds bytes coalesced after the\n upgrade headers (TCP may deliver the 101 and the first WS frame together;\n discarding them would lose the first message). Validates the 101 status\n and Sec-WebSocket-Accept so a non-WebSocket listener fails fast.\n \"\"\"\n deadline = time.monotonic() + timeout\n s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)\n s.settimeout(timeout)\n try:\n s.connect(path)\n key = base64.b64encode(os.urandom(16)).decode()\n expected = base64.b64encode(hashlib.sha1((key + WS_GUID).encode()).digest()).decode()\n req = (\n f\"GET /rpc HTTP/1.1\\r\\nHost: localhost\\r\\nUpgrade: websocket\\r\\n\"\n f\"Connection: Upgrade\\r\\nSec-WebSocket-Key: {key}\\r\\nSec-WebSocket-Version: 13\\r\\n\\r\\n\"\n ).encode()\n s.sendall(req)\n buf = b\"\"\n while True:\n if b\"\\r\\n\\r\\n\" in buf:\n break\n remaining = deadline - time.monotonic()\n if remaining <= 0:\n raise TimeoutError(\"WebSocket upgrade timed out\")\n if len(buf) > 65536:\n raise ConnectionError(\"WS upgrade headers too large\")\n s.settimeout(remaining)\n chunk = s.recv(4096)\n if not chunk:\n raise ConnectionError(\"daemon closed during WS upgrade\")\n buf += chunk\n head, _, trailing = buf.partition(b\"\\r\\n\\r\\n\")\n lines = head.split(b\"\\r\\n\")\n if b\"101\" not in lines[0]:\n raise ConnectionError(f\"WS upgrade failed: {head[:200]!r}\")\n accept = None\n for line in lines[1:]:\n name, sep, value = line.partition(b\":\")\n if sep and name.strip().lower() == b\"sec-websocket-accept\":\n accept = value.strip().decode(\"latin-1\")\n if accept != expected:\n raise ConnectionError(\"WS upgrade accept mismatch\")\n except Exception:\n try:\n s.close()\n except OSError:\n pass\n raise\n s.settimeout(IO_TIMEOUT_S)\n return s, trailing\n\ndef mask_send(sock: socket.socket, payload: bytes, opcode: int = 1) -> None:\n mask = os.urandom(4)\n ln = len(payload)\n if ln < 126:\n hdr = bytes([0x80 | opcode, 0x80 | ln])\n elif ln < 65536:\n hdr = bytes([0x80 | opcode, 0x80 | 126]) + struct.pack(\"!H\", ln)\n else:\n hdr = bytes([0x80 | opcode, 0x80 | 127]) + struct.pack(\"!Q\", ln)\n sock.sendall(hdr + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(payload)))\n\ndef locked_send(sock: socket.socket, lock: threading.Lock, payload: bytes, opcode: int = 1) -> None:\n with lock:\n mask_send(sock, payload, opcode=opcode)\n\ndef _buffered_reader(sock: socket.socket, initial: bytes = b\"\"):\n buf = bytearray(initial)\n def recv(n):\n while len(buf) < n:\n chunk = sock.recv(n - len(buf))\n if not chunk:\n raise ConnectionError(\"eof\")\n buf.extend(chunk)\n out = bytes(buf[:n])\n del buf[:n]\n return out\n return recv\n\ndef read_frame(recv):\n hdr = recv(2)\n fin = bool(hdr[0] & 0x80)\n opcode = hdr[0] & 0x0F\n masked = bool(hdr[1] & 0x80)\n ln = hdr[1] & 0x7F\n if ln == 126:\n ln = struct.unpack(\"!H\", recv(2))[0]\n elif ln == 127:\n ln = struct.unpack(\"!Q\", recv(8))[0]\n mask = recv(4) if masked else b\"\"\n payload = recv(ln)\n if masked:\n payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))\n return fin, opcode, payload\n\ndef read_message(recv, send_pong):\n \"\"\"Next complete data message, assembling continuation frames.\n\n Control frames are handled inline (pong answered, close reported) so a\n fragmented message split around a ping still reassembles.\n \"\"\"\n opcode = None\n parts = []\n while True:\n fin, op, payload = read_frame(recv)\n if op == 0x8:\n return (\"close\", None, b\"\")\n if op == 0x9:\n send_pong(payload)\n continue\n if op == 0xA:\n continue\n if op in (0x1, 0x2):\n if opcode is not None:\n raise ConnectionError(\"data frame inside fragmented message\")\n if fin:\n return (\"data\", op, payload)\n opcode = op\n parts.append(payload)\n continue\n if op == 0x0:\n if opcode is None:\n raise ConnectionError(\"stray continuation frame\")\n parts.append(payload)\n if fin:\n return (\"data\", opcode, b\"\".join(parts))\n continue\n raise ConnectionError(f\"unsupported opcode {op}\")\n\ndef stdout_writer(sock: socket.socket, recv, send_pong, done: threading.Event, first_msg: threading.Event) -> None:\n out = sys.stdout.buffer\n try:\n while True:\n kind, opcode, payload = read_message(recv, send_pong)\n if kind == \"close\":\n log(\"daemon WS close\")\n return\n if opcode == 0x1:\n # Desktop StdioConnection expects newline-delimited JSON text\n if not payload.endswith(b\"\\n\"):\n payload += b\"\\n\"\n out.write(payload)\n out.flush()\n else:\n out.write(payload)\n out.flush()\n first_msg.set()\n except Exception as e:\n log(f\"stdout_writer exit: {e}\")\n finally:\n done.set()\n\ndef main() -> int:\n log(f\"start sock={SOCK}\")\n try:\n sock, pending = ws_connect(SOCK)\n except Exception as e:\n log(f\"connect failed: {e}\")\n sys.stderr.write(f\"codex-stdio-to-daemon-ws: {e}\\n\")\n return EXIT_PRE_STDIO\n log(\"connected\")\n connected_at = time.monotonic()\n send_lock = threading.Lock()\n def send_pong(payload: bytes) -> None:\n locked_send(sock, send_lock, payload, opcode=0xA)\n recv = _buffered_reader(sock, pending)\n done = threading.Event()\n first_msg = threading.Event()\n t = threading.Thread(target=stdout_writer, args=(sock, recv, send_pong, done, first_msg), daemon=True)\n t.start()\n # Byte-precise commit point: the fallback may run only while zero stdin\n # bytes have left the pipe and no stdout frame has been written. Reads use os.read into a userspace line buffer\n # (buffered readline could readahead past the commit point), and every\n # byte read counts — even a blank line. first_msg bounds the first RPC.\n stdin_bytes = 0\n pending_in = b\"\"\n clean_eof = False\n try:\n fd = sys.stdin.fileno()\n while not done.is_set():\n if not first_msg.is_set() and time.monotonic() - connected_at > FIRST_MESSAGE_TIMEOUT_S:\n log(\"first daemon message timed out\")\n break\n ready, _, _ = select.select([fd], [], [], 0.5)\n if not ready:\n continue\n chunk = os.read(fd, 65536)\n if not chunk:\n clean_eof = True\n break\n stdin_bytes += len(chunk)\n pending_in += chunk\n while b\"\\n\" in pending_in:\n line, _, pending_in = pending_in.partition(b\"\\n\")\n line = line.strip()\n if not line:\n continue\n locked_send(sock, send_lock, line)\n except Exception as e:\n log(f\"stdin loop exit: {e}\")\n try:\n locked_send(sock, send_lock, b\"\", opcode=0x8)\n except Exception:\n pass\n if clean_eof:\n tail = pending_in.strip()\n if tail and not done.is_set():\n try:\n locked_send(sock, send_lock, tail)\n except Exception as e:\n log(f\"eof flush failed: {e}\")\n log(\"exit\")\n return EXIT_SERVED\n # Reader died, first RPC timed out, post-upgrade I/O timed out, or stdin\n # broke: fail open only while stdio is still pristine — no stdin byte ever\n # consumed and no stdout frame ever written.\n rc = EXIT_MID_SESSION if (stdin_bytes or first_msg.is_set()) else EXIT_PRE_STDIO\n log(f\"exit rc={rc}\")\n return rc\n\nif __name__ == \"__main__\":\n raise SystemExit(main())\n"; diff --git a/src/core/desktop-shim.js b/src/core/desktop-shim.js index c35da42..7f79ea5 100644 --- a/src/core/desktop-shim.js +++ b/src/core/desktop-shim.js @@ -86,11 +86,12 @@ export function renderWrapperScript({ realPath, bridgePath, wrapperLogPath, code # daemon's control socket. Reversible: \`gbot codex desktop-shim uninstall\`. # The daemon is kept up by the LaunchAgent login script, not here: this hot path # never starts the daemon itself, so a wedged daemon lock cannot hang -# Desktop. Fail-open runs ONLY before any stdin is consumed: an absent socket -# fails the preflight and a pre-session bridge failure (exit 1) falls through +# Desktop. Fail-open runs ONLY while stdio is still pristine (no stdin +# consumed, no stdout written): an absent socket fails the preflight and a +# pre-session bridge failure (exit 1) falls through # to the real standalone codex on pristine stdio. A mid-session bridge failure # (exit 2+) exits promptly so Desktop reconnects; the fallback never runs on -# half-consumed stdin. Bridge exit 0 means it served the session. Never touches +# half-consumed stdin or dirty stdout. Bridge exit 0 means it served the session. Never touches # Desktop binaries or its private tool pipe. set -u REAL="\${CODEX_DESKTOP_WRAPPER_REAL:-${realPath}}" @@ -107,7 +108,11 @@ echo "$(ts) argv: $*" >>"$LOG" 2>/dev/null || true if [[ ! -x "$REAL" ]]; then FALLBACK="$(command -v codex 2>/dev/null || true)" if [[ -n "$FALLBACK" && -x "$FALLBACK" ]]; then - REAL="$FALLBACK" + if [[ "$FALLBACK" -ef "$0" ]] 2>/dev/null || { [[ -n "\${CODEX_CLI_PATH:-}" ]] && [[ "$FALLBACK" -ef "\${CODEX_CLI_PATH}" ]] 2>/dev/null; }; then + echo "$(ts) refusing self fallback $FALLBACK" >>"$LOG" 2>/dev/null || true + else + REAL="$FALLBACK" + fi fi fi @@ -128,7 +133,7 @@ preflight() { } if [[ "$has_app_server" -eq 1 && "$has_daemon" -eq 0 && "$has_proxy" -eq 0 && "$has_generate" -eq 0 ]]; then - if [[ -x "$REAL" && -f "$BRIDGE" ]] && command -v python3 >/dev/null 2>&1; then + if [[ -f "$BRIDGE" ]] && command -v python3 >/dev/null 2>&1; then if preflight; then echo "$(ts) rewrite -> stdio-ws bridge" >>"$LOG" 2>/dev/null || true python3 "$BRIDGE" @@ -339,9 +344,10 @@ export function uninstallDesktopShim({ const warnings = []; const removed = []; for (const path of [paths.wrapperPath, paths.bridgePath, paths.envScriptPath]) { + const existed = fileExists(path); try { if (rmSync(path, { force: true }) === undefined && fileExists(path)) warnings.push(`could not remove ${path}`); - else if (!fileExists(path)) removed.push(path); + else if (existed && !fileExists(path)) removed.push(path); } catch (error) { warnings.push(`could not remove ${path} (${error instanceof Error ? error.message : String(error)})`); } diff --git a/src/core/format.js b/src/core/format.js index 8021bfb..263e45b 100644 --- a/src/core/format.js +++ b/src/core/format.js @@ -125,7 +125,11 @@ export function formatDesktopShimStatus(s) { if (!s.installed) { lines.push("shim: not installed — run `gbot codex desktop-shim install` (Desktop keeps stock behavior until then)"); } else if (!s.wrapperPointsAtShim) { - lines.push("shim: installed but CODEX_CLI_PATH does not point at it — reinstall or relaunch ChatGPT.app after login"); + lines.push( + s.platform === "darwin" + ? "shim: installed but CODEX_CLI_PATH does not point at it — reinstall or relaunch ChatGPT.app after login" + : "shim: installed but CODEX_CLI_PATH does not point at it — export CODEX_CLI_PATH=" + s.wrapperPath, + ); } else { lines.push("shim: active — Desktop app-server spawns bridge onto the managed daemon"); } diff --git a/test/desktop-shim.test.js b/test/desktop-shim.test.js index caad38c..22bc754 100644 --- a/test/desktop-shim.test.js +++ b/test/desktop-shim.test.js @@ -2,13 +2,14 @@ import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { once } from "node:events"; -import { chmodSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { BRIDGE_SOURCE } from "../src/core/desktop-shim-bridge.js"; +import { formatDesktopShimStatus } from "../src/core/format.js"; import { defaultPaths, desktopShimStatus, @@ -683,3 +684,225 @@ test("login script keeps one CODEX_HOME for GUI env and daemon upkeep", () => { assert.match(renderedEnv, /CODEX_HOME_DIR="\/c"/); assert.match(renderedEnv, /launchctl setenv CODEX_HOME "\$CODEX_HOME_DIR"/); }); + +test("wrapper rejects a self fallback and gates the bridge without REAL", () => { + const script = renderWrapperScript({ + bridgeLogPath: "/b/bridge.log", + bridgePath: "/b/bridge.py", + codexHome: "/b", + realPath: "/r/codex", + wrapperLogPath: "/b/w.log", + }); + assert.match(script, /refusing self fallback/); + assert.match(script, /-ef "\$0"/); + assert.match(script, /CODEX_CLI_PATH/); + // The bridge attempt must not require an executable REAL: REAL is only for + // passthrough/fallthrough at the bottom. + assert.doesNotMatch(script, /-x "\$REAL" && -f "\$BRIDGE"/); + assert.match(script, /if \[\[ -f "\$BRIDGE" \]\]/); +}); + +test("vendored bridge pins the I/O timeout and stdout commit", () => { + assert.match(BRIDGE_SOURCE, /CODEX_BRIDGE_IO_TIMEOUT/); + assert.match(BRIDGE_SOURCE, /IO_TIMEOUT_S/); + assert.match(BRIDGE_SOURCE, /first_msg\.is_set\(\)/); + assert.doesNotMatch(BRIDGE_SOURCE, /settimeout\(None\)/); +}); + +test("wrapper refuses a self fallback instead of exec-looping", { + skip: !canRunShellBridge && "needs bash + python3", +}, () => { + const dir = mkdtempSync(join(tmpdir(), "gbot-shim-self-")); + const bridgePath = join(dir, "bridge.py"); + writeFileSync(bridgePath, '#!/usr/bin/env python3\nimport sys; sys.exit(0)\n'); + const wrapperPath = join(dir, "wrapper.sh"); + const script = renderWrapperScript({ + bridgeLogPath: join(dir, "bridge.log"), + bridgePath, + codexHome: dir, + realPath: join(dir, "no-such-codex"), + wrapperLogPath: join(dir, "wrapper.log"), + }); + writeFileSync(wrapperPath, script); + chmodSync(wrapperPath, 0o755); + // Shadow `codex` on PATH with the wrapper itself: the fallback must refuse + // it and fail instead of exec-looping forever. + const binDir = join(dir, "bin"); + mkdirSync(binDir, { recursive: true }); + symlinkSync(wrapperPath, join(binDir, "codex")); + const out = spawnSync("bash", [wrapperPath, "--version"], { + encoding: "utf8", + env: { + ...process.env, + CODEX_APP_SERVER_SOCK: join(dir, "no-such.sock"), + CODEX_CLI_PATH: wrapperPath, + PATH: `${binDir}:${process.env.PATH}`, + }, + timeout: 15000, + }); + assert.equal(out.status, 1); + assert.match(out.stderr, /no runnable codex/); +}); + +test("wrapper attempts the bridge even when REAL is missing", { + skip: !canRunShellBridge && "needs bash + python3", +}, async () => { + const dir = mkdtempSync(join(tmpdir(), "gbot-shim-noreal-")); + const socketPath = join(dir, "fine.sock"); + const server = await silentListener(socketPath); + try { + const bridgePath = join(dir, "bridge-ok.py"); + writeFileSync(bridgePath, '#!/usr/bin/env python3\nimport sys; sys.exit(0)\n'); + const wrapperPath = join(dir, "wrapper.sh"); + const script = renderWrapperScript({ + bridgeLogPath: join(dir, "bridge.log"), + bridgePath, + codexHome: dir, + realPath: join(dir, "no-such-codex"), + wrapperLogPath: join(dir, "wrapper.log"), + }); + writeFileSync(wrapperPath, script); + chmodSync(wrapperPath, 0o755); + const out = runWrapper(wrapperPath, ["app-server"], { CODEX_APP_SERVER_SOCK: socketPath }); + assert.equal(out.status, 0); + assert.doesNotMatch(out.stdout, /FAKE-REAL/); + } finally { + server.close(); + } +}); + +/** Wrapper run that can hold stdin open, mirroring runBridge for bridge-stdout tests. */ +const runWrapperAsync = (wrapperPath, args, env, { input = null, leaveStdinOpen = false } = {}) => + new Promise((resolve, reject) => { + const child = spawn("bash", [wrapperPath, ...args], { env: { ...process.env, ...env } }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + const timer = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(`wrapper hung: stdout=${stdout} stderr=${stderr}`)); + }, 25000); + child.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + child.on("close", (code, signal) => { + clearTimeout(timer); + resolve({ status: code, signal, stdout, stderr }); + }); + if (input) child.stdin.write(input); + if (!leaveStdinOpen) child.stdin.end(); + }); + +test("bridge commits on the first stdout frame: greet-then-close exits mid-session", { + skip: !canRunShellBridge && "needs bash + python3", +}, async () => { + const dir = mkdtempSync(join(tmpdir(), "gbot-shim-greet-")); + const daemon = await fakeWsDaemon(dir, "greet.sock", { + closeAfterMs: 500, + prelude: wsServerFrame(0x1, Buffer.from('{"greet":true}')), + }); + try { + const out = await runBridge(writeBridge(dir), dir, + { CODEX_APP_SERVER_SOCK: daemon.socketPath }, "", { leaveStdinOpen: true }); + assert.equal(out.status, 2); + assert.ok(out.stdout.includes('{"greet":true}'), `greeting must reach stdout, got: ${out.stdout}`); + } finally { + await closeDaemon(daemon); + } +}); + +test("wrapper never falls through after the daemon wrote stdout", { + skip: !canRunShellBridge && "needs bash + python3", +}, async () => { + const dir = mkdtempSync(join(tmpdir(), "gbot-shim-dirty-")); + const daemon = await fakeWsDaemon(dir, "dirty.sock", { + closeAfterMs: 500, + prelude: wsServerFrame(0x1, Buffer.from('{"greet":true}')), + }); + try { + const { wrapperPath } = stageWrapper(dir, { bridgePath: writeBridge(dir) }); + const out = await runWrapperAsync(wrapperPath, ["app-server"], + { CODEX_APP_SERVER_SOCK: daemon.socketPath }, { leaveStdinOpen: true }); + assert.equal(out.status, 2); + assert.ok(out.stdout.includes('{"greet":true}'), `greeting must reach stdout, got: ${out.stdout}`); + assert.doesNotMatch(out.stdout, /FAKE-REAL/); + } finally { + await closeDaemon(daemon); + } +}); + +test("bridge post-upgrade I/O is bounded and exits mid-session once committed", { + skip: !canRunShellBridge && "needs bash + python3", +}, async () => { + const dir = mkdtempSync(join(tmpdir(), "gbot-shim-io-")); + const daemon = await fakeWsDaemon(dir, "wedged.sock", { + prelude: wsServerFrame(0x1, Buffer.from('{"greet":true}')), + quiet: true, + }); + try { + const started = Date.now(); + const out = await runBridge(writeBridge(dir), dir, { + CODEX_APP_SERVER_SOCK: daemon.socketPath, + CODEX_BRIDGE_IO_TIMEOUT: "2", + }, "", { leaveStdinOpen: true }); + const elapsed = Date.now() - started; + assert.equal(out.status, 2); + assert.ok(out.stdout.includes('{"greet":true}'), `greeting must reach stdout, got: ${out.stdout}`); + assert.ok(elapsed < 15000, `post-upgrade I/O must time out instead of hanging, took ${elapsed}ms`); + } finally { + await closeDaemon(daemon); + } +}); + +test("uninstall only reports paths that existed before delete", () => { + const home = mkdtempSync(join(tmpdir(), "gbot-shim-gone-home-")); + const codexHome = mkdtempSync(join(tmpdir(), "gbot-shim-gone-codex-")); + const env = { CODEX_HOME: codexHome, HOME: home }; + const runner = () => ({ status: 0 }); + const empty = uninstallDesktopShim({ env, home, platform: "linux", runner }); + assert.deepEqual(empty.removed, []); + + const installed = installDesktopShim({ env, home, platform: "linux", runner }); + rmSync(installed.bridgePath, { force: true }); + const partial = uninstallDesktopShim({ env, home, platform: "linux", runner }); + assert.ok(!partial.removed.includes(installed.bridgePath), `missing bridge must not be reported: ${partial.removed}`); + assert.ok(partial.removed.includes(installed.wrapperPath)); +}); + +test("linux status never mentions ChatGPT.app", () => { + const base = { + bridgePath: "/b", + bridgePresent: true, + envScriptPath: "/e", + envScriptPresent: true, + installed: true, + socketPath: "/s", + socketSource: "CODEX_HOME", + socketState: "absent", + warnings: [], + wrapperExecutable: true, + wrapperPath: "/w", + wrapperPointsAtShim: false, + wrapperPresent: true, + }; + const linux = formatDesktopShimStatus({ ...base, platform: "linux" }); + assert.doesNotMatch(linux, /ChatGPT\.app/); + assert.match(linux, /CODEX_CLI_PATH=\/w/); + const mac = formatDesktopShimStatus({ + ...base, + cliPath: null, + guiCliPath: null, + platform: "darwin", + plistPath: "/p", + plistPresent: true, + }); + assert.match(mac, /ChatGPT\.app/); +}); From 7da382ee84b215e8d4402488fac49902a4396274 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 15 Sep 2026 22:06:08 +0000 Subject: [PATCH 2/2] chore: retrigger Package CI (no code change) Co-authored-by: Zack Jackson