grok-bot-cli 0.4.2 → 0.5.0

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.
package/dist/bin/gbot.mjs CHANGED
@@ -12994,9 +12994,6 @@ __webpack_require__.r(__webpack_exports__);
12994
12994
 
12995
12995
 
12996
12996
  const config = {
12997
- aliases: [
12998
- 'chat'
12999
- ],
13000
12997
  description: 'Read the most recent messages in a Grok Bot bot or group thread.',
13001
12998
  inputJsonSchema: {
13002
12999
  additionalProperties: false,
@@ -13146,7 +13143,7 @@ __webpack_require__.d(__webpack_exports__, {
13146
13143
 
13147
13144
  // Hosts and the CLI print thrown error text (and stack), and a fetch or proxy failure can echo
13148
13145
  // a credential. Rewrap with a redacted message; keep `name` and own fields such as `reason`,
13149
- // `delivery`, `mode`, and `envelope` so outcome documents still classify the failure.
13146
+ // `delivery`, `mode`, and correlation ids so outcome documents still classify the failure.
13150
13147
  const withRedactedErrors = async (run)=>{
13151
13148
  try {
13152
13149
  return await run();
@@ -84199,9 +84196,7 @@ const commands = Object.freeze([
84199
84196
  "routeId": "cli:send"
84200
84197
  },
84201
84198
  {
84202
- "aliases": [
84203
- "chat"
84204
- ],
84199
+ "aliases": [],
84205
84200
  "description": "Read the most recent messages in a Grok Bot bot or group thread.",
84206
84201
  "exitCode": "zero",
84207
84202
  "options": [
@@ -84503,7 +84498,7 @@ if (import.meta.main) {
84503
84498
  execute,
84504
84499
  name: "gbot",
84505
84500
  render,
84506
- version: "0.4.2"
84501
+ version: "0.5.0"
84507
84502
  });
84508
84503
  }
84509
84504
 
@@ -84741,6 +84736,8 @@ __webpack_require__.d(__webpack_exports__, {
84741
84736
  /* import */ var node_child_process__rspack_import_5 = __webpack_require__("node:child_process");
84742
84737
  /* import */ var node_module__rspack_import_6 = __webpack_require__("node:module");
84743
84738
  /* import */ var _codex_contract_js__rspack_import_7 = __webpack_require__("./src/core/codex/contract.js");
84739
+ /* import */ var _desktop_shim_js__rspack_import_8 = __webpack_require__("./src/core/desktop-shim.js");
84740
+
84744
84741
 
84745
84742
 
84746
84743
 
@@ -84903,15 +84900,27 @@ class CodexRpcError extends Error {
84903
84900
  }
84904
84901
  }
84905
84902
  class CodexSendError extends Error {
84906
- constructor(message, { delivery, threadId, turnId, refused, reason, envelope } = {}){
84903
+ constructor(message, { correlationId, delivery, hop, messageId, reason, refused, threadId, turnId } = {}){
84907
84904
  super(message);
84908
84905
  this.name = "CodexSendError";
84909
84906
  this.delivery = delivery;
84907
+ if (correlationId !== undefined) this.correlationId = correlationId;
84908
+ if (hop !== undefined) this.hop = hop;
84909
+ if (messageId !== undefined) this.messageId = messageId;
84910
84910
  if (reason !== undefined) this.reason = reason;
84911
+ if (refused !== undefined) this.refused = refused;
84911
84912
  if (threadId !== undefined) this.threadId = threadId;
84912
84913
  if (turnId !== undefined) this.turnId = turnId;
84913
- if (refused !== undefined) this.refused = refused;
84914
- if (envelope !== undefined) this.envelope = envelope;
84914
+ }
84915
+ }
84916
+ function attachEnvelope(error, envelope) {
84917
+ if (!error || typeof error !== "object") return;
84918
+ for (const key of [
84919
+ "messageId",
84920
+ "correlationId",
84921
+ "hop"
84922
+ ]){
84923
+ if (error[key] === undefined && envelope[key] !== undefined) error[key] = envelope[key];
84915
84924
  }
84916
84925
  }
84917
84926
  /** The route to the app-server is unavailable; `mode` is a stable machine-readable state. */ class CodexRouteError extends Error {
@@ -84999,11 +85008,42 @@ function isObject(value) {
84999
85008
  const client = {
85000
85009
  refused,
85001
85010
  // Server-initiated requests are only *answered* once our own turn/start
85002
- // is in flight: anything earlier belongs to another client's turn
85003
- // (notably Desktop's) and must never be rejected on their behalf. Early
85004
- // requests are still recorded; the connection closes right after, so a
85005
- // pending request simply dies with it instead of denying someone's approval.
85011
+ // is in flight, and then only when they name our own thread/turn:
85012
+ // anything earlier — or naming another thread or Desktop turn — belongs
85013
+ // to another client and must never be rejected on their behalf. Early
85014
+ // and foreign requests are still recorded; the connection closes right
85015
+ // after, so a pending request simply dies with it instead of denying
85016
+ // someone's approval.
85006
85017
  answerServerRequests: false,
85018
+ expectedThreadId: null,
85019
+ expectedTurnId: null,
85020
+ // Approvals that name a turn while our own turn id is still unknown
85021
+ // (arriving before — or in the same socket batch as — the turn/start
85022
+ // acknowledgment) wait here instead of being classified foreign: once
85023
+ // the acknowledgment supplies the turn id, matching ones are answered
85024
+ // and the rest stay silent.
85025
+ deferred: [],
85026
+ // Adopt the acknowledged turn id, answering deferred requests that
85027
+ // prove to be ours. Runs inside the connection closure so it can send.
85028
+ _adoptTurn (turnId) {
85029
+ client.expectedTurnId = turnId;
85030
+ for (const entry of client.deferred.splice(0)){
85031
+ const params = entry.params && typeof entry.params === "object" && !Array.isArray(entry.params) ? entry.params : null;
85032
+ const threadId = params ? params.threadId ?? params.thread_id : null;
85033
+ const namedTurnId = params ? params.turnId ?? params.turn_id ?? (params.turn && params.turn.id) : null;
85034
+ if (threadId === client.expectedThreadId && namedTurnId === turnId) {
85035
+ entry.answered = true;
85036
+ sendJson({
85037
+ jsonrpc: "2.0",
85038
+ id: entry.id,
85039
+ error: {
85040
+ code: -32601,
85041
+ message: "gbot codex does not answer " + entry.method + "; configure approval_policy on the daemon"
85042
+ }
85043
+ });
85044
+ }
85045
+ }
85046
+ },
85007
85047
  request (method, params) {
85008
85048
  const id = nextId++;
85009
85049
  return new Promise((res, rej)=>{
@@ -85067,13 +85107,36 @@ function isObject(value) {
85067
85107
  };
85068
85108
  const onMessage = (msg)=>{
85069
85109
  if (msg.id != null && msg.method) {
85070
- const answered = client.answerServerRequests;
85071
- refused.push({
85110
+ let answered = client.answerServerRequests;
85111
+ let defer = false;
85112
+ // Approval ownership: only answer requests for the turn gbot itself
85113
+ // started. A request naming another thread — or naming a turn that is
85114
+ // not ours — is recorded foreign-silent, never rejected. A request
85115
+ // naming a turn while our turn id is still unknown is deferred, not
85116
+ // classified: the turn/start acknowledgment decides it.
85117
+ if (answered) {
85118
+ const params = msg.params && typeof msg.params === "object" && !Array.isArray(msg.params) ? msg.params : null;
85119
+ if (params) {
85120
+ const threadId = params.threadId ?? params.thread_id;
85121
+ const turnId = params.turnId ?? params.turn_id ?? (params.turn && params.turn.id);
85122
+ if (threadId !== client.expectedThreadId || turnId == null) answered = false;
85123
+ else if (client.expectedTurnId == null) {
85124
+ answered = false;
85125
+ defer = true;
85126
+ } else if (turnId !== client.expectedTurnId) answered = false;
85127
+ } else answered = false;
85128
+ }
85129
+ const entry = {
85072
85130
  id: msg.id,
85073
85131
  method: msg.method,
85074
85132
  params: msg.params,
85075
85133
  answered
85076
- });
85134
+ };
85135
+ refused.push(entry);
85136
+ if (defer) {
85137
+ client.deferred.push(entry);
85138
+ return;
85139
+ }
85077
85140
  if (!answered) return;
85078
85141
  sendJson({
85079
85142
  jsonrpc: "2.0",
@@ -85318,17 +85381,30 @@ function listDesktopProcesses(timeoutMs = DESKTOP_PROCESS_LIST_TIMEOUT_MS) {
85318
85381
  /**
85319
85382
  * Status contract: `reachable` is endpoint reachability only. `schema.compatibility` is
85320
85383
  * `exact` when the daemon reports the pinned version, otherwise `unverified` (methods
85321
- * usually survive upgrades) or `unknown`. `desktopAttached` is `"private-stdio"` when a
85322
- * Desktop-bundled app-server process is visible (see `detectDesktopPrivateAppServer`),
85323
- * otherwise `"unknown"` — whether Desktop owns any thread is never observable from the
85324
- * socket itself, and `"detached"` is never reported.
85384
+ * usually survive upgrades) or `unknown`. `desktopShimConfigured` records an installed
85385
+ * wrapper selected by Desktop-facing CODEX_CLI_PATH (the GUI domain on Darwin).
85386
+ * Configuration does not prove a running Desktop inherited it or connected.
85387
+ * `desktopAttached` is `"private-stdio"` when a Desktop-bundled app-server process
85388
+ * is visible, otherwise `"unknown"`. Managed attachment is not observable here.
85325
85389
  *
85326
85390
  * @param {NodeJS.ProcessEnv} [env]
85327
- * @param {{ listProcesses?: string | string[] }} [opts] injected process list for tests;
85328
- * defaults to a live `ps` snapshot. Never reads pipes or connects to Desktop.
85329
- */ async function codexStatus(env = process.env, { listProcesses } = {}) {
85391
+ * @param {{ listProcesses?: string | string[], shim?: { installed?: boolean, wrapperPointsAtShim?: boolean } | null }} [opts]
85392
+ * injected process list and shim state for tests; defaults to a live `ps` snapshot and
85393
+ * `desktopShimStatus`. Never reads pipes or connects to Desktop.
85394
+ */ async function codexStatus(env = process.env, { listProcesses, shim } = {}) {
85330
85395
  const path = codexSocketPath(env);
85331
85396
  const cli = probeLocalCodexVersion();
85397
+ let desktopShimConfigured = Boolean(shim && shim.installed && shim.wrapperPointsAtShim);
85398
+ if (shim === undefined) {
85399
+ try {
85400
+ const live = (0,_desktop_shim_js__rspack_import_8/* .desktopShimStatus */.em)({
85401
+ env
85402
+ });
85403
+ desktopShimConfigured = Boolean(live.installed && live.wrapperPointsAtShim);
85404
+ } catch {
85405
+ desktopShimConfigured = false;
85406
+ }
85407
+ }
85332
85408
  const desktopAttached = detectDesktopPrivateAppServer({
85333
85409
  platform: process.platform,
85334
85410
  listProcesses: listProcesses ?? listDesktopProcesses()
@@ -85339,7 +85415,8 @@ function listDesktopProcesses(timeoutMs = DESKTOP_PROCESS_LIST_TIMEOUT_MS) {
85339
85415
  pinnedVersion: PINNED_CODEX_VERSION,
85340
85416
  cliVersion: cli.version,
85341
85417
  cliVersionProbe: cli.probe,
85342
- desktopAttached
85418
+ desktopAttached,
85419
+ desktopShimConfigured
85343
85420
  };
85344
85421
  let session;
85345
85422
  try {
@@ -85498,7 +85575,7 @@ const ID_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/;
85498
85575
  throw new CodexSendError("Refusing to send: hop " + hopCount + " reaches the relay bound " + bound + " (GROK_BOT_MAX_HOPS). " + "This message is an agent-to-agent relay that has already been forwarded too many times.", {
85499
85576
  delivery: "rejected",
85500
85577
  reason: "hop-limit",
85501
- envelope: out
85578
+ ...out
85502
85579
  });
85503
85580
  }
85504
85581
  return out;
@@ -85586,20 +85663,20 @@ function unsupportedOrRpc(err, method, threadId, envelope) {
85586
85663
  delivery: "rejected",
85587
85664
  reason: "unsupported",
85588
85665
  threadId,
85589
- envelope
85666
+ ...envelope
85590
85667
  });
85591
85668
  }
85592
85669
  if (err instanceof CodexRpcError) return new CodexSendError(err.message, {
85593
85670
  delivery: "rejected",
85594
85671
  reason: "rejected",
85595
85672
  threadId,
85596
- envelope
85673
+ ...envelope
85597
85674
  });
85598
85675
  return new CodexSendError("Lost the Codex " + method + " response for thread " + threadId + ": " + (err && err.message || err) + ". Delivery is unknown; list the queue before resending.", {
85599
85676
  delivery: err && err.delivery || "unknown",
85600
85677
  reason: "transport",
85601
85678
  threadId,
85602
- envelope
85679
+ ...envelope
85603
85680
  });
85604
85681
  }
85605
85682
  /** Read the daemon's queue for one thread (experimental `thread/queue/list`). */ async function listCodexQueue(threadId, { env = process.env, limit = 50, cursor } = {}) {
@@ -85650,7 +85727,7 @@ function unsupportedOrRpc(err, method, threadId, envelope) {
85650
85727
  return (0,_codex_contract_js__rspack_import_7/* .outcomeFromReceipt */.Dz)(receipt);
85651
85728
  } catch (err) {
85652
85729
  // Every receipt names the message, including refusals that never reached the daemon.
85653
- if ((err instanceof CodexSendError || err instanceof CodexRouteError || err instanceof CodexProtocolError) && err.envelope === undefined) err.envelope = envelope;
85730
+ if (err instanceof CodexSendError || err instanceof CodexRouteError || err instanceof CodexProtocolError) attachEnvelope(err, envelope);
85654
85731
  return (0,_codex_contract_js__rspack_import_7/* .outcomeFromError */.DG)(err);
85655
85732
  }
85656
85733
  }
@@ -85675,7 +85752,7 @@ async function sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy
85675
85752
  delivery: err instanceof CodexRpcError ? "rejected" : err && err.delivery || "unknown",
85676
85753
  reason: err instanceof CodexRpcError ? /no rollout found|thread not found/i.test(String(err.rpc && err.rpc.message)) ? "unknown-thread" : /active writer/i.test(String(err.rpc && err.rpc.message)) ? "external-owner" : "rejected" : "transport",
85677
85754
  threadId,
85678
- envelope
85755
+ ...envelope
85679
85756
  });
85680
85757
  }
85681
85758
  if (!isObject(resumed) || !isObject(resumed.thread) || typeof resumed.thread.id !== "string") {
@@ -85702,7 +85779,7 @@ async function sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy
85702
85779
  delivery: "rejected",
85703
85780
  reason: "busy",
85704
85781
  threadId,
85705
- envelope
85782
+ ...envelope
85706
85783
  });
85707
85784
  }
85708
85785
  if (state.busy) {
@@ -85727,7 +85804,7 @@ async function sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy
85727
85804
  delivery: "unknown",
85728
85805
  reason: "bad-response",
85729
85806
  threadId,
85730
- envelope
85807
+ ...envelope
85731
85808
  });
85732
85809
  }
85733
85810
  return {
@@ -85741,11 +85818,15 @@ async function sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy
85741
85818
  // the daemon exposes no compare-and-start request. Upgrade path: thread/queue/add + thread/queue/start once stable.
85742
85819
  // Scope refusals to this turn: server requests from earlier calls belong to another context.
85743
85820
  const seenRefused = client.refused.length;
85744
- // Arm refusal replies only now that our own turn/start is in flight: an
85745
- // approval arriving in this window plausibly belongs to our turn. Anything
85746
- // recorded earlier (resume, busy reject, queue) stays unanswered so gbot
85747
- // never rejects another client's approval. Residual race: a concurrent
85748
- // foreign approval inside this window is indistinguishable from ours.
85821
+ // Arm refusal replies only now that our own turn/start is in flight, scoped
85822
+ // to our own thread/turn: anything recorded earlier (resume, busy reject,
85823
+ // queue) — or naming another thread or Desktop turn — stays unanswered so
85824
+ // gbot never rejects another client's approval. Requests naming a turn
85825
+ // that arrives before the acknowledgment supplies our turn id wait in
85826
+ // client.deferred and are adopted only on a match. Requests missing
85827
+ // thread or turn IDs remain unanswered because ownership is unknown.
85828
+ client.expectedThreadId = threadId;
85829
+ client.expectedTurnId = null;
85749
85830
  client.answerServerRequests = true;
85750
85831
  let turn;
85751
85832
  try {
@@ -85769,7 +85850,7 @@ async function sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy
85769
85850
  delivery,
85770
85851
  reason: delivery === "rejected" ? "rejected" : "transport",
85771
85852
  threadId,
85772
- envelope
85853
+ ...envelope
85773
85854
  });
85774
85855
  }
85775
85856
  const turnId = turn && turn.turn && typeof turn.turn.id === "string" && turn.turn.id ? turn.turn.id : null;
@@ -85778,10 +85859,20 @@ async function sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy
85778
85859
  delivery: "unknown",
85779
85860
  reason: "bad-response",
85780
85861
  threadId,
85781
- envelope
85862
+ ...envelope
85782
85863
  });
85783
85864
  }
85784
- const freshRefused = client.refused.slice(seenRefused).filter((r)=>r.answered && (!r.params || r.params.threadId == null || r.params.threadId === threadId));
85865
+ client._adoptTurn(turnId);
85866
+ const freshRefused = client.refused.slice(seenRefused).filter((r)=>{
85867
+ if (!r.answered) return false;
85868
+ const params = r.params && typeof r.params === "object" && !Array.isArray(r.params) ? r.params : null;
85869
+ if (!params) return true;
85870
+ const refusedThreadId = params.threadId ?? params.thread_id;
85871
+ const refusedTurnId = params.turnId ?? params.turn_id ?? (params.turn && params.turn.id);
85872
+ if (refusedThreadId != null && refusedThreadId !== threadId) return false;
85873
+ if (refusedTurnId != null && refusedTurnId !== turnId) return false;
85874
+ return true;
85875
+ });
85785
85876
  if (freshRefused.length) {
85786
85877
  const methods = freshRefused.map((r)=>r.method).join(", ");
85787
85878
  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.", {
@@ -85790,7 +85881,7 @@ async function sendToCodexThreadInner(threadId, text, { env, envelope, whenBusy
85790
85881
  threadId,
85791
85882
  turnId,
85792
85883
  refused: freshRefused.map((r)=>r.method),
85793
- envelope
85884
+ ...envelope
85794
85885
  });
85795
85886
  }
85796
85887
  return {
@@ -85811,8 +85902,7 @@ __webpack_require__.d(__webpack_exports__, {
85811
85902
  X$: () => (listCodexThreads),
85812
85903
  ZS: () => (sendToCodexThread),
85813
85904
  nq: () => (buildEnvelope),
85814
- pF: () => (singleLine),
85815
- ql: () => (codexSocketPath)
85905
+ pF: () => (singleLine)
85816
85906
  });
85817
85907
 
85818
85908
 
@@ -85877,8 +85967,6 @@ __webpack_require__.d(__webpack_exports__, {
85877
85967
  }
85878
85968
  /**
85879
85969
  * Flatten a thrown error into the #46 send/failure document (plus exitCode).
85880
- * Preserves the legacy failure-document fields, then adds exitCode for the
85881
- * generated CLI's `exitCode: 'result'` policy.
85882
85970
  *
85883
85971
  * @param {unknown} error
85884
85972
  * @param {{ isUsage?: (err: unknown) => boolean }} [opts]
@@ -85905,12 +85993,6 @@ __webpack_require__.d(__webpack_exports__, {
85905
85993
  if (error[key] !== undefined) out[key] = error[key];
85906
85994
  }
85907
85995
  if (out.reason === undefined && isUsage(error)) out.reason = "usage";
85908
- const envelope = /** @type {{ messageId?: string, correlationId?: string, hop?: number }} */ error.envelope;
85909
- if (envelope && typeof envelope === "object") {
85910
- if (envelope.messageId !== undefined) out.messageId = envelope.messageId;
85911
- if (envelope.correlationId !== undefined) out.correlationId = envelope.correlationId;
85912
- if (envelope.hop !== undefined) out.hop = envelope.hop;
85913
- }
85914
85996
  }
85915
85997
  if (out.delivery === undefined) out.delivery = "rejected";
85916
85998
  return /** @type {CodexSendOutcome & { error: string, exitCode: 1 }} */ {
@@ -85927,14 +86009,6 @@ __webpack_require__.d(__webpack_exports__, {
85927
86009
  ...receipt
85928
86010
  };
85929
86011
  if (base.delivery === undefined) base.delivery = "accepted";
85930
- // Flatten envelope onto the document when present (matches fail() + json print).
85931
- const envelope = base.envelope;
85932
- if (envelope && typeof envelope === "object") {
85933
- const e = /** @type {Record<string, unknown>} */ envelope;
85934
- if (base.messageId === undefined && e.messageId !== undefined) base.messageId = e.messageId;
85935
- if (base.correlationId === undefined && e.correlationId !== undefined) base.correlationId = e.correlationId;
85936
- if (base.hop === undefined && e.hop !== undefined) base.hop = e.hop;
85937
- }
85938
86012
  const exitCode = sendExitCode(/** @type {{ delivery: CodexDelivery, reason?: string, error?: string }} */ base);
85939
86013
  if (exitCode === 1 && base.error === undefined && base.reason === "approval-refused") {
85940
86014
  base.error = typeof base.message === "string" ? base.message : "Codex refused one or more approvals.";
@@ -85983,7 +86057,7 @@ async function openBackend(opts) {
85983
86057
  setGroupMembers: (group, members)=>_gateway_js__rspack_import_1/* .setGroupMembers */.Td(session, group, members),
85984
86058
  addGroupMember: (group, bot)=>_gateway_js__rspack_import_1/* .addGroupMember */.zr(session, group, bot),
85985
86059
  removeGroupMember: (group, bot)=>_gateway_js__rspack_import_1/* .removeGroupMember */.A0(session, group, bot),
85986
- send: (ref, prompt, extra)=>_gateway_js__rspack_import_1/* .sendPrompt */.LV(session, ref, prompt, extra),
86060
+ send: (ref, prompt)=>_gateway_js__rspack_import_1/* .sendPrompt */.LV(session, ref, prompt),
85987
86061
  transcript: (ref, limit)=>_gateway_js__rspack_import_1/* .getTranscriptTail */.bA(session, ref, limit),
85988
86062
  thread: (ref, rootId)=>_gateway_js__rspack_import_1/* .getThread */.fG(session, ref, rootId)
85989
86063
  };
@@ -86024,24 +86098,36 @@ __webpack_require__.d(__webpack_exports__, {
86024
86098
  // Codex app-server control socket. Vendored from uploads/codex-stdio-to-daemon-ws.py
86025
86099
  // (ChatGPT Desktop -> managed daemon shim) with fail-open deltas, marked below:
86026
86100
  // (1) ws_connect enforces one absolute monotonic deadline for connect + HTTP
86027
- // upgrade (CODEX_BRIDGE_CONNECT_TIMEOUT, default 10s), validates the 101 status
86028
- // and Sec-WebSocket-Accept, and retains bytes coalesced after the upgrade
86029
- // headers instead of discarding the first data frame.
86101
+ // upgrade (CODEX_BRIDGE_CONNECT_TIMEOUT, default 10s), requires HTTP/1.1 101
86102
+ // plus a matching Sec-WebSocket-Accept (a 200 fails even with a valid hash),
86103
+ // and retains bytes coalesced after the upgrade headers instead of discarding
86104
+ // the first data frame.
86030
86105
  // (2) read_message assembles continuation frames; control frames answer inline;
86031
- // socket writes are serialized with a lock shared by both threads.
86032
- // (3) A first-message deadline (CODEX_BRIDGE_FIRST_MESSAGE_TIMEOUT, default
86033
- // 30s) bounds connect-to-first-daemon-message covering the first RPC; the
86034
- // preflight (3s) + upgrade (10s) + first RPC (30s) budget stays far below a
86035
- // daemon-lock hang. Exits are 0 served, 1 pre-stdio (wrapper falls back to
86036
- // real Codex), 2 mid-session (wrapper exits so Desktop reconnects).
86037
- // (4) Byte-precise commit: os.read into a userspace line buffer counts every
86038
- // stdin byte (buffered readline could readahead past the commit point), so the
86039
- // fallback only ever runs on a truly pristine stdio. The reader signals a done
86040
- // event so WS close wakes the select loop; no path leaves a thread hung.
86106
+ // every socket send/lock shares one monotonic budget (remaining first-RPC
86107
+ // budget, else the mid-session write budget) so a non-reading peer cannot
86108
+ // wedge pong writes or timeout cleanup; timeouts close and exit. Reads wait
86109
+ // with no timeout after the handshake so healthy idle sessions survive.
86110
+ // The wrapper never starts the daemon on the spawn path, so no daemon lock can
86111
+ // block pre-stdin Desktop.
86112
+ // (3) A first-response deadline (CODEX_BRIDGE_FIRST_MESSAGE_TIMEOUT, default
86113
+ // 30s) bounds connect-to-matching-daemon-response covering the first RPC;
86114
+ // unrelated notifications and foreign responses never satisfy it, only the
86115
+ // matching response/error (or timeout) clears it. The preflight (3s) +
86116
+ // upgrade (10s) + first RPC (30s) budget stays far below a daemon-lock hang.
86117
+ // Exits are 0 served, 1 pre-stdio (wrapper falls back to real Codex on pristine
86118
+ // stdio+stdout), 2 mid-session (wrapper exits so Desktop reconnects).
86119
+ // (4) Byte-precise commit on input OR output, committed BEFORE the stdout
86120
+ // write: os.read into a userspace line buffer counts every stdin byte (even
86121
+ // an unforwarded blank line), and any daemon payload marks stdout used before
86122
+ // it is written via a bounded stdout write, so the fallback only ever runs
86123
+ // on truly pristine stdio+stdout and never spawns a second Codex after used
86124
+ // stdout. Buffered EOF-tail data flushes before the WS Close, which drains
86125
+ // via shutdown+close with a bounded join. The reader signals a done event so
86126
+ // WS close wakes the select loop; no path leaves a thread hung.
86041
86127
  // Desktop stdio hangs with stock `codex app-server proxy`, so this custom
86042
86128
  // bridge is required. Install writes it to the CODEX_HOME bin dir. Never reads
86043
86129
  // Desktop private pipes (CODEX_APP_TOOLS_PIPE_PATH, /tmp/codex-browser-use/).
86044
- 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";
86130
+ 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";
86045
86131
 
86046
86132
  __webpack_require__.d(__webpack_exports__, {
86047
86133
  }, {
@@ -86055,9 +86141,7 @@ __webpack_require__.d(__webpack_exports__, {
86055
86141
  /* import */ var node_fs__rspack_import_1 = __webpack_require__("node:fs");
86056
86142
  /* import */ var node_os__rspack_import_2 = __webpack_require__("node:os");
86057
86143
  /* import */ var node_path__rspack_import_3 = __webpack_require__("node:path");
86058
- /* import */ var _desktop_shim_bridge_js__rspack_import_5 = __webpack_require__("./src/core/desktop-shim-bridge.js");
86059
- /* import */ var _codex_bridge_js__rspack_import_4 = __webpack_require__("./src/core/codex-bridge.js");
86060
-
86144
+ /* import */ var _desktop_shim_bridge_js__rspack_import_4 = __webpack_require__("./src/core/desktop-shim-bridge.js");
86061
86145
 
86062
86146
 
86063
86147
 
@@ -86135,11 +86219,12 @@ function renderWrapperScript({ realPath, bridgePath, wrapperLogPath, codexHome,
86135
86219
  # daemon's control socket. Reversible: \`gbot codex desktop-shim uninstall\`.
86136
86220
  # The daemon is kept up by the LaunchAgent login script, not here: this hot path
86137
86221
  # never starts the daemon itself, so a wedged daemon lock cannot hang
86138
- # Desktop. Fail-open runs ONLY before any stdin is consumed: an absent socket
86139
- # fails the preflight and a pre-session bridge failure (exit 1) falls through
86222
+ # Desktop. Fail-open runs ONLY while stdio is still pristine (no stdin
86223
+ # consumed, no stdout written): an absent socket fails the preflight and a
86224
+ # pre-session bridge failure (exit 1) falls through
86140
86225
  # to the real standalone codex on pristine stdio. A mid-session bridge failure
86141
86226
  # (exit 2+) exits promptly so Desktop reconnects; the fallback never runs on
86142
- # half-consumed stdin. Bridge exit 0 means it served the session. Never touches
86227
+ # half-consumed stdin or dirty stdout. Bridge exit 0 means it served the session. Never touches
86143
86228
  # Desktop binaries or its private tool pipe.
86144
86229
  set -u
86145
86230
  REAL="\${CODEX_DESKTOP_WRAPPER_REAL:-${realPath}}"
@@ -86156,7 +86241,11 @@ echo "$(ts) argv: $*" >>"$LOG" 2>/dev/null || true
86156
86241
  if [[ ! -x "$REAL" ]]; then
86157
86242
  FALLBACK="$(command -v codex 2>/dev/null || true)"
86158
86243
  if [[ -n "$FALLBACK" && -x "$FALLBACK" ]]; then
86159
- REAL="$FALLBACK"
86244
+ if [[ "$FALLBACK" -ef "$0" ]] 2>/dev/null || { [[ -n "\${CODEX_CLI_PATH:-}" ]] && [[ "$FALLBACK" -ef "\${CODEX_CLI_PATH}" ]] 2>/dev/null; }; then
86245
+ echo "$(ts) refusing self fallback $FALLBACK" >>"$LOG" 2>/dev/null || true
86246
+ else
86247
+ REAL="$FALLBACK"
86248
+ fi
86160
86249
  fi
86161
86250
  fi
86162
86251
 
@@ -86177,7 +86266,7 @@ preflight() {
86177
86266
  }
86178
86267
 
86179
86268
  if [[ "$has_app_server" -eq 1 && "$has_daemon" -eq 0 && "$has_proxy" -eq 0 && "$has_generate" -eq 0 ]]; then
86180
- if [[ -x "$REAL" && -f "$BRIDGE" ]] && command -v python3 >/dev/null 2>&1; then
86269
+ if [[ -f "$BRIDGE" ]] && command -v python3 >/dev/null 2>&1; then
86181
86270
  if preflight; then
86182
86271
  echo "$(ts) rewrite -> stdio-ws bridge" >>"$LOG" 2>/dev/null || true
86183
86272
  python3 "$BRIDGE"
@@ -86327,7 +86416,7 @@ function fileExists(path) {
86327
86416
  recursive: true
86328
86417
  });
86329
86418
  writeExecutable(paths.wrapperPath, renderWrapperScript(paths));
86330
- (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`, {
86419
+ (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`, {
86331
86420
  mode: 420
86332
86421
  });
86333
86422
  writeExecutable(paths.envScriptPath, renderEnvScript(paths));
@@ -86407,11 +86496,12 @@ function fileExists(path) {
86407
86496
  paths.bridgePath,
86408
86497
  paths.envScriptPath
86409
86498
  ]){
86499
+ const existed = fileExists(path);
86410
86500
  try {
86411
86501
  if ((0,node_fs__rspack_import_1.rmSync)(path, {
86412
86502
  force: true
86413
86503
  }) === undefined && fileExists(path)) warnings.push(`could not remove ${path}`);
86414
- else if (!fileExists(path)) removed.push(path);
86504
+ else if (existed && !fileExists(path)) removed.push(path);
86415
86505
  } catch (error) {
86416
86506
  warnings.push(`could not remove ${path} (${error instanceof Error ? error.message : String(error)})`);
86417
86507
  }
@@ -86505,12 +86595,10 @@ function socketStateOf(path) {
86505
86595
  // Desktop-facing value: the GUI domain on macOS, the caller env elsewhere.
86506
86596
  const desktopCliPath = platform === "darwin" ? guiCliPath : cliPath;
86507
86597
  const installed = wrapperPresent && isExecutable(paths.wrapperPath) && bridgePresent;
86508
- // Effective socket, resolved exactly like gbot's own client so status and
86509
- // send/queue/status never disagree: CODEX_APP_SERVER_SOCK wins, else CODEX_HOME.
86510
- const socketPath = (0,_codex_bridge_js__rspack_import_4/* .codexSocketPath */.ql)({
86511
- ...env,
86512
- CODEX_HOME: paths.codexHome
86513
- });
86598
+ // Effective socket, resolved exactly like gbot's own client (CODEX_APP_SERVER_SOCK
86599
+ // wins, else the install-time CODEX_HOME) so status and send/queue/status never
86600
+ // disagree. Inlined here so codex-bridge can reuse desktopShimStatus without a cycle.
86601
+ const socketPath = env.CODEX_APP_SERVER_SOCK || (0,node_path__rspack_import_3.join)(paths.codexHome, "app-server-control", "app-server-control.sock");
86514
86602
  return {
86515
86603
  action: "status",
86516
86604
  bridgePath: paths.bridgePath,
@@ -86605,7 +86693,7 @@ function formatTranscript(out, { full = false } = {}) {
86605
86693
  for (const e of entries){
86606
86694
  const role = e.role || e.kind || e.sender || e.type || "msg";
86607
86695
  const text = (0,_transcript_js__rspack_import_0/* .entryText */.u0)(e);
86608
- const id = e.id || e.messageId || "";
86696
+ const id = e.id || "";
86609
86697
  lines.push("[" + role + (id ? " " + id : "") + "] " + (full ? text : truncateCliText(text)));
86610
86698
  }
86611
86699
  return lines.join("\n");
@@ -86618,6 +86706,7 @@ function formatCodexStatus(s) {
86618
86706
  const lines = [
86619
86707
  "socket: " + s.socketPath + " (" + s.socketState + ")"
86620
86708
  ];
86709
+ if (s.desktopShimConfigured) lines.push("desktop shim: configured (managed attachment unverified)");
86621
86710
  if (!s.reachable) return lines.concat("reachable: no (" + s.mode + ")", s.message).join("\n");
86622
86711
  if (s.mode !== "daemon") {
86623
86712
  return lines.concat("reachable: yes, but unusable (" + s.mode + ")", s.message).join("\n");
@@ -86625,7 +86714,7 @@ function formatCodexStatus(s) {
86625
86714
  lines.push("reachable: yes (daemon)");
86626
86715
  const cli = s.cliVersion ?? (s.cliVersionProbe === "ok" ? "unknown" : "unknown, probe " + s.cliVersionProbe);
86627
86716
  lines.push("daemon version: " + (s.daemonVersion ?? "unknown") + " cli version: " + cli + " pinned schema: " + s.pinnedVersion + " (" + s.schema.compatibility + ")");
86628
- 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)");
86717
+ 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)");
86629
86718
  if (s.versionMismatch) {
86630
86719
  lines.push("warning: daemon and CLI versions differ; `codex app-server daemon restart` picks up the installed CLI");
86631
86720
  }
@@ -86640,6 +86729,9 @@ function formatCodexThread(t) {
86640
86729
  const preview = t.preview ? "\n " + stripTerminalControls(String(t.preview).replace(/\s+/g, " ")).slice(0, 200) : "";
86641
86730
  return stripTerminalControls(t.id) + " " + stripTerminalControls(t.status) + title + "\n " + stripTerminalControls(t.cwd ?? "") + preview;
86642
86731
  }
86732
+ /** Shell-safe single-quoting for copy-pasteable export lines (spaces, quotes, $). */ function shellQuote(value) {
86733
+ return "'" + String(value).replace(/'/g, "'\\''") + "'";
86734
+ }
86643
86735
  function formatDesktopShimStatus(s) {
86644
86736
  const mark = (ok)=>ok ? "yes" : "no";
86645
86737
  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)" : "");
@@ -86654,7 +86746,7 @@ function formatDesktopShimStatus(s) {
86654
86746
  if (!s.installed) {
86655
86747
  lines.push("shim: not installed — run `gbot codex desktop-shim install` (Desktop keeps stock behavior until then)");
86656
86748
  } else if (!s.wrapperPointsAtShim) {
86657
- lines.push("shim: installed but CODEX_CLI_PATH does not point at it — reinstall or relaunch ChatGPT.app after login");
86749
+ 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));
86658
86750
  } else {
86659
86751
  lines.push("shim: active — Desktop app-server spawns bridge onto the managed daemon");
86660
86752
  }
@@ -86697,19 +86789,17 @@ class GatewayError extends Error {
86697
86789
  const GATEWAY_TIMEOUT_MS = 30000;
86698
86790
  const GATEWAY_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
86699
86791
  function backendBase() {
86700
- return (process.env.SAND_BACKEND_URL || process.env.CURSOR_API_BASE_URL || "https://api2.cursor.sh").replace(/\/$/, "");
86792
+ return (process.env.CURSOR_API_BASE_URL || "https://api2.cursor.sh").replace(/\/$/, "");
86701
86793
  }
86702
86794
  function accessTokenFromEnv() {
86703
- return (process.env.CURSOR_ACCESS_TOKEN || process.env.GROK_BOT_ACCESS_TOKEN || process.env.SAND_ACCESS_TOKEN || "").trim();
86795
+ return (process.env.CURSOR_ACCESS_TOKEN || "").trim();
86704
86796
  }
86705
86797
  function gatewayTokenFromEnv() {
86706
- return (process.env.GROK_BOT_GATEWAY_TOKEN || process.env.SAND_HOST_GATEWAY_TOKEN || process.env.SAND_GATEWAY_TOKEN || "").trim();
86798
+ return (process.env.GROK_BOT_GATEWAY_TOKEN || "").trim();
86707
86799
  }
86708
86800
  function gatewayOverride() {
86709
86801
  const token = gatewayTokenFromEnv();
86710
- const explicitUrl = (process.env.GROK_BOT_GATEWAY_URL || process.env.SAND_HOST_GATEWAY_URL || "").trim();
86711
- const localUrl = token ? "http://127.0.0.1:" + (process.env.SAND_HOST_PORT || "1340") : "";
86712
- const url = explicitUrl || localUrl;
86802
+ const url = (process.env.GROK_BOT_GATEWAY_URL || "").trim();
86713
86803
  if (url && token) {
86714
86804
  return {
86715
86805
  gatewayUrl: (0,_url_policy_js__rspack_import_3/* .assertAllowedCredentialUrl */.Re)(url.replace(/\/$/, ""), {
@@ -87029,14 +87119,13 @@ async function removeGroupMember(session, groupRef, memberRef) {
87029
87119
  const next = group.memberIds.filter((id)=>id !== member.id);
87030
87120
  return setGroupMembers(session, group.id, next);
87031
87121
  }
87032
- async function sendPrompt(session, ref, prompt, extra = {}) {
87122
+ async function sendPrompt(session, ref, prompt) {
87033
87123
  const rec = await resolveRef(session, ref);
87034
87124
  const body = {
87035
87125
  agentId: rec.id,
87036
87126
  prompt,
87037
- clientNonce: extra.clientNonce || (0,node_crypto__rspack_import_0.randomUUID)()
87127
+ clientNonce: (0,node_crypto__rspack_import_0.randomUUID)()
87038
87128
  };
87039
- if (extra.replyToId) body.replyToId = extra.replyToId;
87040
87129
  let data;
87041
87130
  try {
87042
87131
  data = await gatewayCall(session, "sendPrompt", body);
@@ -87213,8 +87302,8 @@ function saveHistory(out, { dir, disabled, event, prompt, rootId } = {}) {
87213
87302
  rootId
87214
87303
  } : {},
87215
87304
  role: String(entry.role || entry.kind || entry.sender || entry.type || "msg"),
87216
- ...entry.id || entry.messageId ? {
87217
- messageId: String(entry.id || entry.messageId)
87305
+ ...entry.id ? {
87306
+ messageId: String(entry.id)
87218
87307
  } : {},
87219
87308
  ...entry.timestamp || entry.createdAt ? {
87220
87309
  timestamp: String(entry.timestamp || entry.createdAt)
@@ -87358,10 +87447,7 @@ function isUuid(value) {
87358
87447
  function defaultCandidateRoots() {
87359
87448
  const home = (0,node_os__rspack_import_2.homedir)();
87360
87449
  const env = [
87361
- process.env.GROK_BOT_AGENTS_DIR,
87362
- process.env.SAND_AGENTS_DIR,
87363
- process.env.SAND_DATA_ROOT && (0,node_path__rspack_import_3.join)(process.env.SAND_DATA_ROOT, "agents"),
87364
- process.env.SAND_DATA_ROOT && (0,node_path__rspack_import_3.join)(process.env.SAND_DATA_ROOT, "agent-data", "agents")
87450
+ process.env.GROK_BOT_AGENTS_DIR
87365
87451
  ].filter(Boolean);
87366
87452
  return [
87367
87453
  ...env,
@@ -87640,7 +87726,6 @@ __webpack_require__.d(__webpack_exports__, {
87640
87726
 
87641
87727
  },
87642
87728
  "./src/core/transcript.js"(__unused_rspack___webpack_module__, __webpack_exports__, __webpack_require__) {
87643
- // Shared by `gbot thread` and the grok-bot plugin's gbot_thread tool.
87644
87729
  /** Coerce anything to a string without throwing (numbers, BigInt, unserializable objects). */ function toSafeText(value) {
87645
87730
  if (typeof value === "string") return value;
87646
87731
  if (value == null) return "";
@@ -87678,15 +87763,12 @@ function entryTextRaw(e) {
87678
87763
  return typeof e.preview === "string" ? e.preview : "";
87679
87764
  }
87680
87765
  function transcriptEntries(payload) {
87681
- if (Array.isArray(payload)) return payload;
87682
87766
  if (!payload || typeof payload !== "object") return [];
87683
- const entries = payload.entries || payload.messages || payload.items;
87684
- return Array.isArray(entries) ? entries : [];
87767
+ return Array.isArray(payload.entries) ? payload.entries : [];
87685
87768
  }
87686
87769
  function sourceEntryId(entry) {
87687
87770
  if (!entry || typeof entry !== "object") return "";
87688
- if (typeof entry.id === "string" && entry.id) return entry.id;
87689
- return typeof entry.messageId === "string" ? entry.messageId : "";
87771
+ return typeof entry.id === "string" ? entry.id : "";
87690
87772
  }
87691
87773
  function lastSourceId(entries, fallback = "") {
87692
87774
  for(let index = entries.length - 1; index >= 0; index -= 1){
@@ -87751,6 +87833,8 @@ __webpack_require__.d(__webpack_exports__, {
87751
87833
  * never *.cursorvm.com, so a CURSOR_ACCESS_TOKEN cannot be pointed at a box host.
87752
87834
  * Local/dev gateways: http(s)://127.0.0.1|localhost|::1 when GROK_BOT_ALLOW_LOCAL_GATEWAY=1.
87753
87835
  * Escape hatch: GROK_BOT_ALLOW_ANY_GATEWAY=1 (unsafe; disables host checks; warns once).
87836
+ * Test mode (GROK_BOT_TEST=1 or NODE_ENV=test): loopback only, for gateway and backend
87837
+ * alike, and the escape hatches are ignored — a test can never reach a live thread.
87754
87838
  */ function truthyEnv(name) {
87755
87839
  const v = (process.env[name] || "").trim().toLowerCase();
87756
87840
  return v === "1" || v === "true" || v === "yes";
@@ -87761,6 +87845,9 @@ function allowAnyGateway() {
87761
87845
  function allowLocalGateway() {
87762
87846
  return truthyEnv("GROK_BOT_ALLOW_LOCAL_GATEWAY");
87763
87847
  }
87848
+ function testMode() {
87849
+ return truthyEnv("GROK_BOT_TEST") || process.env.NODE_ENV === "test";
87850
+ }
87764
87851
  const warned = new Set();
87765
87852
  function warnOnce(key, message) {
87766
87853
  if (warned.has(key)) return;
@@ -87800,6 +87887,12 @@ function isCursorGatewayHostname(hostname) {
87800
87887
  if (parsed.username || parsed.password) {
87801
87888
  throw new Error("Rejected " + label + ": userinfo is not allowed.");
87802
87889
  }
87890
+ if (testMode()) {
87891
+ if (!isLocalHostname(parsed.hostname) || parsed.protocol !== "http:" && parsed.protocol !== "https:") {
87892
+ throw new Error("Rejected " + label + " host \"" + parsed.hostname + "\": test mode (GROK_BOT_TEST / NODE_ENV=test) only allows http(s) loopback gateways.");
87893
+ }
87894
+ return String(rawUrl).replace(/\/$/, "");
87895
+ }
87803
87896
  if (allowAnyGateway()) {
87804
87897
  warnOnce("ALLOW_ANY", "warning: GROK_BOT_ALLOW_ANY_GATEWAY is set; credential host checks are disabled.");
87805
87898
  return String(rawUrl).replace(/\/$/, "");