grok-bot-cli 0.4.2 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26410,6 +26410,8 @@ __webpack_require__.d(__webpack_exports__, {
26410
26410
  /* import */ var node_child_process__rspack_import_5 = __webpack_require__("node:child_process");
26411
26411
  /* import */ var node_module__rspack_import_6 = __webpack_require__("node:module");
26412
26412
  /* import */ var _codex_contract_js__rspack_import_7 = __webpack_require__("./src/core/codex/contract.js");
26413
+ /* import */ var _desktop_shim_js__rspack_import_8 = __webpack_require__("./src/core/desktop-shim.js");
26414
+
26413
26415
 
26414
26416
 
26415
26417
 
@@ -26668,11 +26670,42 @@ function isObject(value) {
26668
26670
  const client = {
26669
26671
  refused,
26670
26672
  // Server-initiated requests are only *answered* once our own turn/start
26671
- // is in flight: anything earlier belongs to another client's turn
26672
- // (notably Desktop's) and must never be rejected on their behalf. Early
26673
- // requests are still recorded; the connection closes right after, so a
26674
- // pending request simply dies with it instead of denying someone's approval.
26673
+ // is in flight, and then only when they name our own thread/turn:
26674
+ // anything earlier — or naming another thread or Desktop turn — belongs
26675
+ // to another client and must never be rejected on their behalf. Early
26676
+ // and foreign requests are still recorded; the connection closes right
26677
+ // after, so a pending request simply dies with it instead of denying
26678
+ // someone's approval.
26675
26679
  answerServerRequests: false,
26680
+ expectedThreadId: null,
26681
+ expectedTurnId: null,
26682
+ // Approvals that name a turn while our own turn id is still unknown
26683
+ // (arriving before — or in the same socket batch as — the turn/start
26684
+ // acknowledgment) wait here instead of being classified foreign: once
26685
+ // the acknowledgment supplies the turn id, matching ones are answered
26686
+ // and the rest stay silent.
26687
+ deferred: [],
26688
+ // Adopt the acknowledged turn id, answering deferred requests that
26689
+ // prove to be ours. Runs inside the connection closure so it can send.
26690
+ _adoptTurn (turnId) {
26691
+ client.expectedTurnId = turnId;
26692
+ for (const entry of client.deferred.splice(0)){
26693
+ const params = entry.params && typeof entry.params === "object" && !Array.isArray(entry.params) ? entry.params : null;
26694
+ const threadId = params ? params.threadId ?? params.thread_id : null;
26695
+ const namedTurnId = params ? params.turnId ?? params.turn_id ?? (params.turn && params.turn.id) : null;
26696
+ if (threadId === client.expectedThreadId && namedTurnId === turnId) {
26697
+ entry.answered = true;
26698
+ sendJson({
26699
+ jsonrpc: "2.0",
26700
+ id: entry.id,
26701
+ error: {
26702
+ code: -32601,
26703
+ message: "gbot codex does not answer " + entry.method + "; configure approval_policy on the daemon"
26704
+ }
26705
+ });
26706
+ }
26707
+ }
26708
+ },
26676
26709
  request (method, params) {
26677
26710
  const id = nextId++;
26678
26711
  return new Promise((res, rej)=>{
@@ -26736,13 +26769,36 @@ function isObject(value) {
26736
26769
  };
26737
26770
  const onMessage = (msg)=>{
26738
26771
  if (msg.id != null && msg.method) {
26739
- const answered = client.answerServerRequests;
26740
- refused.push({
26772
+ let answered = client.answerServerRequests;
26773
+ let defer = false;
26774
+ // Approval ownership: only answer requests for the turn gbot itself
26775
+ // started. A request naming another thread — or naming a turn that is
26776
+ // not ours — is recorded foreign-silent, never rejected. A request
26777
+ // naming a turn while our turn id is still unknown is deferred, not
26778
+ // classified: the turn/start acknowledgment decides it.
26779
+ if (answered) {
26780
+ const params = msg.params && typeof msg.params === "object" && !Array.isArray(msg.params) ? msg.params : null;
26781
+ if (params) {
26782
+ const threadId = params.threadId ?? params.thread_id;
26783
+ const turnId = params.turnId ?? params.turn_id ?? (params.turn && params.turn.id);
26784
+ if (threadId !== client.expectedThreadId || turnId == null) answered = false;
26785
+ else if (client.expectedTurnId == null) {
26786
+ answered = false;
26787
+ defer = true;
26788
+ } else if (turnId !== client.expectedTurnId) answered = false;
26789
+ } else answered = false;
26790
+ }
26791
+ const entry = {
26741
26792
  id: msg.id,
26742
26793
  method: msg.method,
26743
26794
  params: msg.params,
26744
26795
  answered
26745
- });
26796
+ };
26797
+ refused.push(entry);
26798
+ if (defer) {
26799
+ client.deferred.push(entry);
26800
+ return;
26801
+ }
26746
26802
  if (!answered) return;
26747
26803
  sendJson({
26748
26804
  jsonrpc: "2.0",
@@ -26987,17 +27043,30 @@ function listDesktopProcesses(timeoutMs = DESKTOP_PROCESS_LIST_TIMEOUT_MS) {
26987
27043
  /**
26988
27044
  * Status contract: `reachable` is endpoint reachability only. `schema.compatibility` is
26989
27045
  * `exact` when the daemon reports the pinned version, otherwise `unverified` (methods
26990
- * usually survive upgrades) or `unknown`. `desktopAttached` is `"private-stdio"` when a
26991
- * Desktop-bundled app-server process is visible (see `detectDesktopPrivateAppServer`),
26992
- * otherwise `"unknown"` — whether Desktop owns any thread is never observable from the
26993
- * socket itself, and `"detached"` is never reported.
27046
+ * usually survive upgrades) or `unknown`. `desktopShimConfigured` records an installed
27047
+ * wrapper selected by Desktop-facing CODEX_CLI_PATH (the GUI domain on Darwin).
27048
+ * Configuration does not prove a running Desktop inherited it or connected.
27049
+ * `desktopAttached` is `"private-stdio"` when a Desktop-bundled app-server process
27050
+ * is visible, otherwise `"unknown"`. Managed attachment is not observable here.
26994
27051
  *
26995
27052
  * @param {NodeJS.ProcessEnv} [env]
26996
- * @param {{ listProcesses?: string | string[] }} [opts] injected process list for tests;
26997
- * defaults to a live `ps` snapshot. Never reads pipes or connects to Desktop.
26998
- */ async function codexStatus(env = process.env, { listProcesses } = {}) {
27053
+ * @param {{ listProcesses?: string | string[], shim?: { installed?: boolean, wrapperPointsAtShim?: boolean } | null }} [opts]
27054
+ * injected process list and shim state for tests; defaults to a live `ps` snapshot and
27055
+ * `desktopShimStatus`. Never reads pipes or connects to Desktop.
27056
+ */ async function codexStatus(env = process.env, { listProcesses, shim } = {}) {
26999
27057
  const path = codexSocketPath(env);
27000
27058
  const cli = probeLocalCodexVersion();
27059
+ let desktopShimConfigured = Boolean(shim && shim.installed && shim.wrapperPointsAtShim);
27060
+ if (shim === undefined) {
27061
+ try {
27062
+ const live = (0,_desktop_shim_js__rspack_import_8/* .desktopShimStatus */.em)({
27063
+ env
27064
+ });
27065
+ desktopShimConfigured = Boolean(live.installed && live.wrapperPointsAtShim);
27066
+ } catch {
27067
+ desktopShimConfigured = false;
27068
+ }
27069
+ }
27001
27070
  const desktopAttached = detectDesktopPrivateAppServer({
27002
27071
  platform: process.platform,
27003
27072
  listProcesses: listProcesses ?? listDesktopProcesses()
@@ -27008,7 +27077,8 @@ function listDesktopProcesses(timeoutMs = DESKTOP_PROCESS_LIST_TIMEOUT_MS) {
27008
27077
  pinnedVersion: PINNED_CODEX_VERSION,
27009
27078
  cliVersion: cli.version,
27010
27079
  cliVersionProbe: cli.probe,
27011
- desktopAttached
27080
+ desktopAttached,
27081
+ desktopShimConfigured
27012
27082
  };
27013
27083
  let session;
27014
27084
  try {
@@ -27410,11 +27480,15 @@ async function sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy
27410
27480
  // the daemon exposes no compare-and-start request. Upgrade path: thread/queue/add + thread/queue/start once stable.
27411
27481
  // Scope refusals to this turn: server requests from earlier calls belong to another context.
27412
27482
  const seenRefused = client.refused.length;
27413
- // Arm refusal replies only now that our own turn/start is in flight: an
27414
- // approval arriving in this window plausibly belongs to our turn. Anything
27415
- // recorded earlier (resume, busy reject, queue) stays unanswered so gbot
27416
- // never rejects another client's approval. Residual race: a concurrent
27417
- // foreign approval inside this window is indistinguishable from ours.
27483
+ // Arm refusal replies only now that our own turn/start is in flight, scoped
27484
+ // to our own thread/turn: anything recorded earlier (resume, busy reject,
27485
+ // queue) — or naming another thread or Desktop turn — stays unanswered so
27486
+ // gbot never rejects another client's approval. Requests naming a turn
27487
+ // that arrives before the acknowledgment supplies our turn id wait in
27488
+ // client.deferred and are adopted only on a match. Requests missing
27489
+ // thread or turn IDs remain unanswered because ownership is unknown.
27490
+ client.expectedThreadId = threadId;
27491
+ client.expectedTurnId = null;
27418
27492
  client.answerServerRequests = true;
27419
27493
  let turn;
27420
27494
  try {
@@ -27450,7 +27524,17 @@ async function sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy
27450
27524
  envelope
27451
27525
  });
27452
27526
  }
27453
- const freshRefused = client.refused.slice(seenRefused).filter((r)=>r.answered && (!r.params || r.params.threadId == null || r.params.threadId === threadId));
27527
+ client._adoptTurn(turnId);
27528
+ const freshRefused = client.refused.slice(seenRefused).filter((r)=>{
27529
+ if (!r.answered) return false;
27530
+ const params = r.params && typeof r.params === "object" && !Array.isArray(r.params) ? r.params : null;
27531
+ if (!params) return true;
27532
+ const refusedThreadId = params.threadId ?? params.thread_id;
27533
+ const refusedTurnId = params.turnId ?? params.turn_id ?? (params.turn && params.turn.id);
27534
+ if (refusedThreadId != null && refusedThreadId !== threadId) return false;
27535
+ if (refusedTurnId != null && refusedTurnId !== turnId) return false;
27536
+ return true;
27537
+ });
27454
27538
  if (freshRefused.length) {
27455
27539
  const methods = freshRefused.map((r)=>r.method).join(", ");
27456
27540
  throw new CodexSendError("Turn " + turnId + " started on thread " + threadId + " but Codex asked for " + methods + ", which gbot refused. " + "Answer it in a Codex client, or set `approval_policy = \"never\"` in the daemon's config.toml for unattended sends.", {
@@ -27480,8 +27564,7 @@ __webpack_require__.d(__webpack_exports__, {
27480
27564
  X$: () => (listCodexThreads),
27481
27565
  ZS: () => (sendToCodexThread),
27482
27566
  nq: () => (buildEnvelope),
27483
- pF: () => (singleLine),
27484
- ql: () => (codexSocketPath)
27567
+ pF: () => (singleLine)
27485
27568
  });
27486
27569
 
27487
27570
 
@@ -27693,24 +27776,36 @@ __webpack_require__.d(__webpack_exports__, {
27693
27776
  // Codex app-server control socket. Vendored from uploads/codex-stdio-to-daemon-ws.py
27694
27777
  // (ChatGPT Desktop -> managed daemon shim) with fail-open deltas, marked below:
27695
27778
  // (1) ws_connect enforces one absolute monotonic deadline for connect + HTTP
27696
- // upgrade (CODEX_BRIDGE_CONNECT_TIMEOUT, default 10s), validates the 101 status
27697
- // and Sec-WebSocket-Accept, and retains bytes coalesced after the upgrade
27698
- // headers instead of discarding the first data frame.
27779
+ // upgrade (CODEX_BRIDGE_CONNECT_TIMEOUT, default 10s), requires HTTP/1.1 101
27780
+ // plus a matching Sec-WebSocket-Accept (a 200 fails even with a valid hash),
27781
+ // and retains bytes coalesced after the upgrade headers instead of discarding
27782
+ // the first data frame.
27699
27783
  // (2) read_message assembles continuation frames; control frames answer inline;
27700
- // socket writes are serialized with a lock shared by both threads.
27701
- // (3) A first-message deadline (CODEX_BRIDGE_FIRST_MESSAGE_TIMEOUT, default
27702
- // 30s) bounds connect-to-first-daemon-message covering the first RPC; the
27703
- // preflight (3s) + upgrade (10s) + first RPC (30s) budget stays far below a
27704
- // daemon-lock hang. Exits are 0 served, 1 pre-stdio (wrapper falls back to
27705
- // real Codex), 2 mid-session (wrapper exits so Desktop reconnects).
27706
- // (4) Byte-precise commit: os.read into a userspace line buffer counts every
27707
- // stdin byte (buffered readline could readahead past the commit point), so the
27708
- // fallback only ever runs on a truly pristine stdio. The reader signals a done
27709
- // event so WS close wakes the select loop; no path leaves a thread hung.
27784
+ // every socket send/lock shares one monotonic budget (remaining first-RPC
27785
+ // budget, else the mid-session write budget) so a non-reading peer cannot
27786
+ // wedge pong writes or timeout cleanup; timeouts close and exit. Reads wait
27787
+ // with no timeout after the handshake so healthy idle sessions survive.
27788
+ // The wrapper never starts the daemon on the spawn path, so no daemon lock can
27789
+ // block pre-stdin Desktop.
27790
+ // (3) A first-response deadline (CODEX_BRIDGE_FIRST_MESSAGE_TIMEOUT, default
27791
+ // 30s) bounds connect-to-matching-daemon-response covering the first RPC;
27792
+ // unrelated notifications and foreign responses never satisfy it, only the
27793
+ // matching response/error (or timeout) clears it. The preflight (3s) +
27794
+ // upgrade (10s) + first RPC (30s) budget stays far below a daemon-lock hang.
27795
+ // Exits are 0 served, 1 pre-stdio (wrapper falls back to real Codex on pristine
27796
+ // stdio+stdout), 2 mid-session (wrapper exits so Desktop reconnects).
27797
+ // (4) Byte-precise commit on input OR output, committed BEFORE the stdout
27798
+ // write: os.read into a userspace line buffer counts every stdin byte (even
27799
+ // an unforwarded blank line), and any daemon payload marks stdout used before
27800
+ // it is written via a bounded stdout write, so the fallback only ever runs
27801
+ // on truly pristine stdio+stdout and never spawns a second Codex after used
27802
+ // stdout. Buffered EOF-tail data flushes before the WS Close, which drains
27803
+ // via shutdown+close with a bounded join. The reader signals a done event so
27804
+ // WS close wakes the select loop; no path leaves a thread hung.
27710
27805
  // Desktop stdio hangs with stock `codex app-server proxy`, so this custom
27711
27806
  // bridge is required. Install writes it to the CODEX_HOME bin dir. Never reads
27712
27807
  // Desktop private pipes (CODEX_APP_TOOLS_PIPE_PATH, /tmp/codex-browser-use/).
27713
- 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";
27808
+ 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 committed (wrapper\n# falls back to real Codex), 2 failed mid-session (wrapper exits promptly so\n# Desktop reconnects; the fallback must never run on half-consumed stdin or\n# 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 matching first daemon response: a daemon that\n# completes the upgrade but never answers the first RPC must not hold Desktop\n# past this. Only the matching initialize response/error (or the deadline\n# itself) clears it; notifications never do.\nFIRST_MESSAGE_TIMEOUT_S = float(os.environ.get(\"CODEX_BRIDGE_FIRST_MESSAGE_TIMEOUT\", \"30\"))\n# Bound on every mid-session socket send and stdout write: a wedged or\n# non-reading peer must not hang Desktop past this. Reads after a successful\n# init block with no timeout so healthy idle sessions survive silence.\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). Requires HTTP/1.1 101 plus\n a matching Sec-WebSocket-Accept so a 200 (or any non-101) fails fast even\n when the accept hash happens to be valid.\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.settimeout(max(0.0, deadline - time.monotonic()))\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 not lines or lines[0].split(b\" \", 2)[:2] != [b\"HTTP/1.1\", b\"101\"]:\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 # Keep one socket mode across reader and writer threads. Reads wait\n # indefinitely in select; sends use their own absolute deadline.\n s.setblocking(False)\n return s, trailing\n\ndef mask_send(sock: socket.socket, payload: bytes, opcode: int = 1, deadline=None) -> 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 view = memoryview(hdr + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(payload)))\n while view:\n remaining = deadline - time.monotonic()\n if remaining <= 0:\n raise TimeoutError(\"socket write timed out\")\n _, writable, _ = select.select([], [sock], [], remaining)\n if not writable:\n raise TimeoutError(\"socket write timed out\")\n try:\n n = sock.send(view)\n except BlockingIOError:\n continue\n if not n:\n raise ConnectionError(\"socket closed during write\")\n view = view[n:]\n\ndef locked_send(sock: socket.socket, lock: threading.Lock, payload: bytes, opcode: int = 1, timeout: float = CONNECT_TIMEOUT_S) -> None:\n \"\"\"Serialized send sharing one monotonic budget across lock wait + send.\n\n A non-reading peer must never wedge pong writes or timeout cleanup: on\n expiry raise TimeoutError so the caller closes and exits without hanging.\n \"\"\"\n if timeout is None:\n timeout = CONNECT_TIMEOUT_S\n deadline = time.monotonic() + timeout\n if timeout <= 0:\n raise TimeoutError(\"send budget expired\")\n if not lock.acquire(timeout=timeout):\n raise TimeoutError(\"send lock timed out\")\n try:\n remaining = deadline - time.monotonic()\n if remaining <= 0:\n raise TimeoutError(\"send budget expired\")\n mask_send(sock, payload, opcode=opcode, deadline=deadline)\n finally:\n lock.release()\n\ndef _buffered_reader(sock: socket.socket, initial: bytes = b\"\"):\n buf = bytearray(initial)\n def recv(n):\n while len(buf) < n:\n select.select([sock], [], [])\n try:\n chunk = sock.recv(n - len(buf))\n except BlockingIOError:\n continue\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 _is_daemon_response(payload: bytes, expect_id=None, known_ids=None) -> bool:\n \"\"\"True only for the matching JSON-RPC response/error, never a notification.\n\n The first-RPC timer must survive unrelated daemon notifications and foreign\n responses: only the response/error whose id matches the client's initialize\n (or, before it is known, any id the client already sent) proves the first\n RPC got an answer. Timeouts clear it via the deadline, never via data.\n \"\"\"\n try:\n text = payload.decode(\"utf-8\").strip()\n msg = json.loads(text)\n except Exception:\n return False\n if not isinstance(msg, dict):\n return False\n rid = msg.get(\"id\")\n if rid is None or \"method\" in msg or (\"result\" not in msg and \"error\" not in msg):\n return False\n if expect_id is not None:\n return rid == expect_id\n if known_ids:\n return rid in known_ids\n return False\n\ndef _bounded_stdout_write(data: bytes, timeout: float = IO_TIMEOUT_S) -> None:\n \"\"\"Write one daemon frame to stdout without hanging on a non-reading peer.\n\n Waits for writability under the mid-session write budget and writes via\n os.write in a loop, so a Desktop that stopped reading cannot hang the\n bridge and then exit 1 on dirty stdout.\n \"\"\"\n if timeout is None:\n timeout = IO_TIMEOUT_S\n if timeout <= 0:\n raise TimeoutError(\"stdout budget expired\")\n fd = sys.stdout.fileno()\n deadline = time.monotonic() + timeout\n was_blocking = os.get_blocking(fd)\n os.set_blocking(fd, False)\n try:\n view = memoryview(data)\n while view:\n remaining = deadline - time.monotonic()\n if remaining <= 0:\n raise TimeoutError(\"stdout write timed out\")\n _, writable, _ = select.select([], [fd], [], remaining)\n if not writable:\n raise TimeoutError(\"stdout write timed out\")\n try:\n n = os.write(fd, view)\n except BlockingIOError:\n continue\n view = view[n:]\n finally:\n os.set_blocking(fd, was_blocking)\n\ndef stdout_writer(sock: socket.socket, recv, send_pong, done: threading.Event, first_msg: threading.Event, output_forwarded: threading.Event, client_ids=None, init_state=None) -> None:\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 # Commit BEFORE writing: once a daemon byte is about to hit\n # stdout, the session is dirty and the fallback must never run,\n # even if the write itself blocks or fails.\n output_forwarded.set()\n _bounded_stdout_write(payload)\n if opcode == 0x1:\n expect = (init_state or {}).get(\"initialize_id\") if isinstance(init_state, dict) else None\n if _is_daemon_response(payload, expect, client_ids or ()):\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 first_deadline = connected_at + FIRST_MESSAGE_TIMEOUT_S\n send_lock = threading.Lock()\n done = threading.Event()\n first_msg = threading.Event()\n output_forwarded = threading.Event()\n client_ids = set()\n init_state = {\"initialize_id\": None}\n\n def _send_timeout() -> float:\n # Before the first matching daemon response the remaining first-RPC\n # budget bounds every send; after it each send gets the mid-session\n # write budget. Idle reads are never timed out.\n if not first_msg.is_set():\n return max(0.0, first_deadline - time.monotonic())\n return IO_TIMEOUT_S\n\n def _cleanup_timeout() -> float:\n # Timeout cleanup stays within the expired session budget: once the\n # first-RPC deadline has passed, close directly instead of opening\n # fresh windows that let a short budget take several timeout periods.\n if not first_msg.is_set():\n return max(0.0, first_deadline - time.monotonic())\n return CONNECT_TIMEOUT_S\n\n def send_pong(payload: bytes) -> None:\n locked_send(sock, send_lock, payload, opcode=0xA, timeout=_send_timeout())\n recv = _buffered_reader(sock, pending)\n t = threading.Thread(target=stdout_writer, args=(sock, recv, send_pong, done, first_msg, output_forwarded, client_ids, init_state), 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 daemon payload was committed to stdout.\n # Reads use os.read into a userspace line buffer (buffered readline could\n # readahead past the commit point), and every byte read counts -- even a\n # blank line. first_msg tracks the matching daemon response/error, never\n # a notification.\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() >= first_deadline:\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 try:\n try:\n obj = json.loads(line.decode(\"utf-8\"))\n except Exception:\n obj = None\n if isinstance(obj, dict) and obj.get(\"id\") is not None:\n client_ids.add(obj.get(\"id\"))\n if obj.get(\"method\") == \"initialize\" and init_state[\"initialize_id\"] is None:\n init_state[\"initialize_id\"] = obj.get(\"id\")\n except Exception:\n pass\n try:\n locked_send(sock, send_lock, line, timeout=_send_timeout())\n except TimeoutError as e:\n log(f\"stdin forward timed out: {e}\")\n break\n except Exception as e:\n log(f\"stdin forward failed: {e}\")\n break\n else:\n continue\n break\n except Exception as e:\n log(f\"stdin loop exit: {e}\")\n # Flush any buffered EOF-tail data BEFORE the WS Close: sending Close\n # first would let the daemon discard the tail.\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, timeout=_cleanup_timeout())\n except Exception as e:\n log(f\"eof flush failed: {e}\")\n try:\n locked_send(sock, send_lock, b\"\", opcode=0x8, timeout=_cleanup_timeout())\n except Exception as e:\n log(f\"close send failed: {e}\")\n try:\n try:\n sock.shutdown(socket.SHUT_RDWR)\n except OSError:\n pass\n sock.close()\n except OSError:\n pass\n # Bound termination: wake the reader so no thread is left hung on recv.\n t.join(timeout=0.5)\n # A reader still exiting could commit output after this check. In that\n # case pristine stdio cannot be proven, so never permit fallback.\n if t.is_alive():\n output_forwarded.set()\n if clean_eof:\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\n # ever consumed AND no daemon payload ever committed to stdout. Committed\n # output forces a mid-session exit so Desktop reconnects instead of\n # falling back to stock Codex on a corrupted stream.\n rc = EXIT_MID_SESSION if (stdin_bytes or output_forwarded.is_set()) else EXIT_PRE_STDIO\n log(f\"exit rc={rc}\")\n return rc\n\nif __name__ == \"__main__\":\n raise SystemExit(main())\n";
27714
27809
 
27715
27810
  __webpack_require__.d(__webpack_exports__, {
27716
27811
  }, {
@@ -27724,9 +27819,7 @@ __webpack_require__.d(__webpack_exports__, {
27724
27819
  /* import */ var node_fs__rspack_import_1 = __webpack_require__("node:fs");
27725
27820
  /* import */ var node_os__rspack_import_2 = __webpack_require__("node:os");
27726
27821
  /* import */ var node_path__rspack_import_3 = __webpack_require__("node:path");
27727
- /* import */ var _desktop_shim_bridge_js__rspack_import_5 = __webpack_require__("./src/core/desktop-shim-bridge.js");
27728
- /* import */ var _codex_bridge_js__rspack_import_4 = __webpack_require__("./src/core/codex-bridge.js");
27729
-
27822
+ /* import */ var _desktop_shim_bridge_js__rspack_import_4 = __webpack_require__("./src/core/desktop-shim-bridge.js");
27730
27823
 
27731
27824
 
27732
27825
 
@@ -27804,11 +27897,12 @@ function renderWrapperScript({ realPath, bridgePath, wrapperLogPath, codexHome,
27804
27897
  # daemon's control socket. Reversible: \`gbot codex desktop-shim uninstall\`.
27805
27898
  # The daemon is kept up by the LaunchAgent login script, not here: this hot path
27806
27899
  # never starts the daemon itself, so a wedged daemon lock cannot hang
27807
- # Desktop. Fail-open runs ONLY before any stdin is consumed: an absent socket
27808
- # fails the preflight and a pre-session bridge failure (exit 1) falls through
27900
+ # Desktop. Fail-open runs ONLY while stdio is still pristine (no stdin
27901
+ # consumed, no stdout written): an absent socket fails the preflight and a
27902
+ # pre-session bridge failure (exit 1) falls through
27809
27903
  # to the real standalone codex on pristine stdio. A mid-session bridge failure
27810
27904
  # (exit 2+) exits promptly so Desktop reconnects; the fallback never runs on
27811
- # half-consumed stdin. Bridge exit 0 means it served the session. Never touches
27905
+ # half-consumed stdin or dirty stdout. Bridge exit 0 means it served the session. Never touches
27812
27906
  # Desktop binaries or its private tool pipe.
27813
27907
  set -u
27814
27908
  REAL="\${CODEX_DESKTOP_WRAPPER_REAL:-${realPath}}"
@@ -27825,7 +27919,11 @@ echo "$(ts) argv: $*" >>"$LOG" 2>/dev/null || true
27825
27919
  if [[ ! -x "$REAL" ]]; then
27826
27920
  FALLBACK="$(command -v codex 2>/dev/null || true)"
27827
27921
  if [[ -n "$FALLBACK" && -x "$FALLBACK" ]]; then
27828
- REAL="$FALLBACK"
27922
+ if [[ "$FALLBACK" -ef "$0" ]] 2>/dev/null || { [[ -n "\${CODEX_CLI_PATH:-}" ]] && [[ "$FALLBACK" -ef "\${CODEX_CLI_PATH}" ]] 2>/dev/null; }; then
27923
+ echo "$(ts) refusing self fallback $FALLBACK" >>"$LOG" 2>/dev/null || true
27924
+ else
27925
+ REAL="$FALLBACK"
27926
+ fi
27829
27927
  fi
27830
27928
  fi
27831
27929
 
@@ -27846,7 +27944,7 @@ preflight() {
27846
27944
  }
27847
27945
 
27848
27946
  if [[ "$has_app_server" -eq 1 && "$has_daemon" -eq 0 && "$has_proxy" -eq 0 && "$has_generate" -eq 0 ]]; then
27849
- if [[ -x "$REAL" && -f "$BRIDGE" ]] && command -v python3 >/dev/null 2>&1; then
27947
+ if [[ -f "$BRIDGE" ]] && command -v python3 >/dev/null 2>&1; then
27850
27948
  if preflight; then
27851
27949
  echo "$(ts) rewrite -> stdio-ws bridge" >>"$LOG" 2>/dev/null || true
27852
27950
  python3 "$BRIDGE"
@@ -27996,7 +28094,7 @@ function fileExists(path) {
27996
28094
  recursive: true
27997
28095
  });
27998
28096
  writeExecutable(paths.wrapperPath, renderWrapperScript(paths));
27999
- (0,node_fs__rspack_import_1.writeFileSync)(paths.bridgePath, _desktop_shim_bridge_js__rspack_import_5/* .BRIDGE_SOURCE.endsWith */.w.endsWith("\n") ? _desktop_shim_bridge_js__rspack_import_5/* .BRIDGE_SOURCE */.w : `${_desktop_shim_bridge_js__rspack_import_5/* .BRIDGE_SOURCE */.w}\n`, {
28097
+ (0,node_fs__rspack_import_1.writeFileSync)(paths.bridgePath, _desktop_shim_bridge_js__rspack_import_4/* .BRIDGE_SOURCE.endsWith */.w.endsWith("\n") ? _desktop_shim_bridge_js__rspack_import_4/* .BRIDGE_SOURCE */.w : `${_desktop_shim_bridge_js__rspack_import_4/* .BRIDGE_SOURCE */.w}\n`, {
28000
28098
  mode: 420
28001
28099
  });
28002
28100
  writeExecutable(paths.envScriptPath, renderEnvScript(paths));
@@ -28076,11 +28174,12 @@ function fileExists(path) {
28076
28174
  paths.bridgePath,
28077
28175
  paths.envScriptPath
28078
28176
  ]){
28177
+ const existed = fileExists(path);
28079
28178
  try {
28080
28179
  if ((0,node_fs__rspack_import_1.rmSync)(path, {
28081
28180
  force: true
28082
28181
  }) === undefined && fileExists(path)) warnings.push(`could not remove ${path}`);
28083
- else if (!fileExists(path)) removed.push(path);
28182
+ else if (existed && !fileExists(path)) removed.push(path);
28084
28183
  } catch (error) {
28085
28184
  warnings.push(`could not remove ${path} (${error instanceof Error ? error.message : String(error)})`);
28086
28185
  }
@@ -28174,12 +28273,10 @@ function socketStateOf(path) {
28174
28273
  // Desktop-facing value: the GUI domain on macOS, the caller env elsewhere.
28175
28274
  const desktopCliPath = platform === "darwin" ? guiCliPath : cliPath;
28176
28275
  const installed = wrapperPresent && isExecutable(paths.wrapperPath) && bridgePresent;
28177
- // Effective socket, resolved exactly like gbot's own client so status and
28178
- // send/queue/status never disagree: CODEX_APP_SERVER_SOCK wins, else CODEX_HOME.
28179
- const socketPath = (0,_codex_bridge_js__rspack_import_4/* .codexSocketPath */.ql)({
28180
- ...env,
28181
- CODEX_HOME: paths.codexHome
28182
- });
28276
+ // Effective socket, resolved exactly like gbot's own client (CODEX_APP_SERVER_SOCK
28277
+ // wins, else the install-time CODEX_HOME) so status and send/queue/status never
28278
+ // disagree. Inlined here so codex-bridge can reuse desktopShimStatus without a cycle.
28279
+ const socketPath = env.CODEX_APP_SERVER_SOCK || (0,node_path__rspack_import_3.join)(paths.codexHome, "app-server-control", "app-server-control.sock");
28183
28280
  return {
28184
28281
  action: "status",
28185
28282
  bridgePath: paths.bridgePath,
@@ -28287,6 +28384,7 @@ function formatCodexStatus(s) {
28287
28384
  const lines = [
28288
28385
  "socket: " + s.socketPath + " (" + s.socketState + ")"
28289
28386
  ];
28387
+ if (s.desktopShimConfigured) lines.push("desktop shim: configured (managed attachment unverified)");
28290
28388
  if (!s.reachable) return lines.concat("reachable: no (" + s.mode + ")", s.message).join("\n");
28291
28389
  if (s.mode !== "daemon") {
28292
28390
  return lines.concat("reachable: yes, but unusable (" + s.mode + ")", s.message).join("\n");
@@ -28294,7 +28392,7 @@ function formatCodexStatus(s) {
28294
28392
  lines.push("reachable: yes (daemon)");
28295
28393
  const cli = s.cliVersion ?? (s.cliVersionProbe === "ok" ? "unknown" : "unknown, probe " + s.cliVersionProbe);
28296
28394
  lines.push("daemon version: " + (s.daemonVersion ?? "unknown") + " cli version: " + cli + " pinned schema: " + s.pinnedVersion + " (" + s.schema.compatibility + ")");
28297
- lines.push(s.desktopAttached === "private-stdio" ? "desktop attached: private-stdio (ChatGPT Desktop runs its own private stdio app-server; start a managed daemon with `codex app-server daemon start`)" : "desktop attached: unknown (not observable from the socket)");
28395
+ lines.push(s.desktopAttached === "private-stdio" ? "desktop attached: private-stdio (Desktop-bundled app-server process observed; managed attachment unverified)" : "desktop attached: unknown (not observable from the socket)");
28298
28396
  if (s.versionMismatch) {
28299
28397
  lines.push("warning: daemon and CLI versions differ; `codex app-server daemon restart` picks up the installed CLI");
28300
28398
  }
@@ -28309,6 +28407,9 @@ function formatCodexThread(t) {
28309
28407
  const preview = t.preview ? "\n " + stripTerminalControls(String(t.preview).replace(/\s+/g, " ")).slice(0, 200) : "";
28310
28408
  return stripTerminalControls(t.id) + " " + stripTerminalControls(t.status) + title + "\n " + stripTerminalControls(t.cwd ?? "") + preview;
28311
28409
  }
28410
+ /** Shell-safe single-quoting for copy-pasteable export lines (spaces, quotes, $). */ function shellQuote(value) {
28411
+ return "'" + String(value).replace(/'/g, "'\\''") + "'";
28412
+ }
28312
28413
  function formatDesktopShimStatus(s) {
28313
28414
  const mark = (ok)=>ok ? "yes" : "no";
28314
28415
  const cliLine = s.platform === "darwin" ? "CODEX_CLI_PATH: GUI domain " + (s.guiCliPath ?? "(unset)") + " / this shell " + (s.cliPath ?? "(unset)") + (s.wrapperPointsAtShim ? " (Desktop-facing value points at shim)" : "") : "CODEX_CLI_PATH: " + (s.cliPath ?? "(unset)") + (s.wrapperPointsAtShim ? " (points at shim)" : "");
@@ -28323,7 +28424,7 @@ function formatDesktopShimStatus(s) {
28323
28424
  if (!s.installed) {
28324
28425
  lines.push("shim: not installed — run `gbot codex desktop-shim install` (Desktop keeps stock behavior until then)");
28325
28426
  } else if (!s.wrapperPointsAtShim) {
28326
- lines.push("shim: installed but CODEX_CLI_PATH does not point at it — reinstall or relaunch ChatGPT.app after login");
28427
+ 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=" + shellQuote(s.wrapperPath));
28327
28428
  } else {
28328
28429
  lines.push("shim: active — Desktop app-server spawns bridge onto the managed daemon");
28329
28430
  }
@@ -29420,6 +29521,8 @@ __webpack_require__.d(__webpack_exports__, {
29420
29521
  * never *.cursorvm.com, so a CURSOR_ACCESS_TOKEN cannot be pointed at a box host.
29421
29522
  * Local/dev gateways: http(s)://127.0.0.1|localhost|::1 when GROK_BOT_ALLOW_LOCAL_GATEWAY=1.
29422
29523
  * Escape hatch: GROK_BOT_ALLOW_ANY_GATEWAY=1 (unsafe; disables host checks; warns once).
29524
+ * Test mode (GROK_BOT_TEST=1 or NODE_ENV=test): loopback only, for gateway and backend
29525
+ * alike, and the escape hatches are ignored — a test can never reach a live thread.
29423
29526
  */ function truthyEnv(name) {
29424
29527
  const v = (process.env[name] || "").trim().toLowerCase();
29425
29528
  return v === "1" || v === "true" || v === "yes";
@@ -29430,6 +29533,9 @@ function allowAnyGateway() {
29430
29533
  function allowLocalGateway() {
29431
29534
  return truthyEnv("GROK_BOT_ALLOW_LOCAL_GATEWAY");
29432
29535
  }
29536
+ function testMode() {
29537
+ return truthyEnv("GROK_BOT_TEST") || process.env.NODE_ENV === "test";
29538
+ }
29433
29539
  const warned = new Set();
29434
29540
  function warnOnce(key, message) {
29435
29541
  if (warned.has(key)) return;
@@ -29469,6 +29575,12 @@ function isCursorGatewayHostname(hostname) {
29469
29575
  if (parsed.username || parsed.password) {
29470
29576
  throw new Error("Rejected " + label + ": userinfo is not allowed.");
29471
29577
  }
29578
+ if (testMode()) {
29579
+ if (!isLocalHostname(parsed.hostname) || parsed.protocol !== "http:" && parsed.protocol !== "https:") {
29580
+ throw new Error("Rejected " + label + " host \"" + parsed.hostname + "\": test mode (GROK_BOT_TEST / NODE_ENV=test) only allows http(s) loopback gateways.");
29581
+ }
29582
+ return String(rawUrl).replace(/\/$/, "");
29583
+ }
29472
29584
  if (allowAnyGateway()) {
29473
29585
  warnOnce("ALLOW_ANY", "warning: GROK_BOT_ALLOW_ANY_GATEWAY is set; credential host checks are disabled.");
29474
29586
  return String(rawUrl).replace(/\/$/, "");