grok-bot-cli 0.3.0 → 0.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grok-bot-cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "CLI for Grok Bot agents and groups: create, update, message, inspect threads, and automate cleanup",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -16,7 +16,16 @@ function print(value) {
16
16
  function fail(err) {
17
17
  let message = err instanceof Error ? err.message : String(err);
18
18
  message = redactSecrets(message);
19
- process.stderr.write(message + "\n");
19
+ if (jsonErrors && err instanceof Error) {
20
+ const out = { error: message };
21
+ if (err.delivery !== undefined) out.delivery = err.delivery;
22
+ if (err.threadId !== undefined) out.threadId = err.threadId;
23
+ if (err.turnId !== undefined) out.turnId = err.turnId;
24
+ if (err.targetId !== undefined) out.targetId = err.targetId;
25
+ process.stderr.write(JSON.stringify(out) + "\n");
26
+ } else {
27
+ process.stderr.write(message + "\n");
28
+ }
20
29
  process.exit(1);
21
30
  }
22
31
 
@@ -118,7 +127,19 @@ function hasFlag(args, name) {
118
127
  return true;
119
128
  }
120
129
 
130
+ /** Peel a trailing flag that `--` does not protect; free-text commands keep mid-text tokens. */
131
+ function takeTrailingFlag(args, name) {
132
+ const stop = args.indexOf("--");
133
+ const end = stop === -1 ? args.length : stop;
134
+ if (end > 0 && args[end - 1] === name) {
135
+ args.splice(end - 1, 1);
136
+ return true;
137
+ }
138
+ return false;
139
+ }
140
+
121
141
  /** Peel global CLI options only from the leading argv (before the command). */
142
+ let jsonErrors = false;
122
143
  function takeLeadingGlobals(args) {
123
144
  let json = false;
124
145
  let gateway = false;
@@ -134,6 +155,7 @@ function takeLeadingGlobals(args) {
134
155
  }
135
156
  if (a === "--json") {
136
157
  json = true;
158
+ jsonErrors = true;
137
159
  args.shift();
138
160
  continue;
139
161
  }
@@ -301,6 +323,11 @@ function formatCodexThread(t) {
301
323
  }
302
324
 
303
325
  async function runCodex(sub, rest, json) {
326
+ // Structured subcommands take no free text, so --json peels anywhere. Send
327
+ // peels a trailing --json only; mid-message tokens stay message content.
328
+ if (sub === "status" || sub === "list-threads") {
329
+ if (hasFlag(rest, "--json")) { json = true; jsonErrors = true; }
330
+ }
304
331
  if (sub === "status") {
305
332
  const status = await codexStatus();
306
333
  print(json ? status : formatCodexStatus(status));
@@ -319,6 +346,7 @@ async function runCodex(sub, rest, json) {
319
346
  }
320
347
  if (sub === "send") {
321
348
  const threadId = rest.shift();
349
+ if (takeTrailingFlag(rest, "--json")) { json = true; jsonErrors = true; }
322
350
  if (rest[0] === "--") rest.shift();
323
351
  const message = rest.join(" ").trim();
324
352
  if (!threadId || threadId.startsWith("-") || !message) throw new StoreError("gbot codex send <threadId> <message...>");
@@ -379,7 +407,7 @@ async function main(argv) {
379
407
  if (cmd === "history") {
380
408
  const options = args.slice(1);
381
409
  // Command-local flags (globals only peel from argv before the command).
382
- if (hasFlag(options, "--json")) json = true;
410
+ if (hasFlag(options, "--json")) { json = true; jsonErrors = true; }
383
411
  const showPath = hasFlag(options, "--path");
384
412
  const search = takeFlag(options, "--search");
385
413
  const limitRaw = takeFlag(options, "--limit");
@@ -405,6 +433,12 @@ async function main(argv) {
405
433
  return;
406
434
  }
407
435
 
436
+ // Peel command-local --json before touching the backend so auth/gateway
437
+ // failures honor it. Send peels a trailing --json only (mid-text tokens stay
438
+ // message content); `--` protects everything after it.
439
+ if (cmd === "send" && takeTrailingFlag(rest, "--json")) { json = true; jsonErrors = true; }
440
+ if ((cmd === "thread" || cmd === "chat") && hasFlag(rest, "--json")) { json = true; jsonErrors = true; }
441
+
408
442
  const backend = await openBackend({ root: rootFlag, gateway, files: filesMode });
409
443
 
410
444
  if (cmd === "bots" && sub === "list") {
@@ -514,7 +548,7 @@ async function main(argv) {
514
548
 
515
549
  if (cmd === "send") {
516
550
  const ref = sub;
517
- if (hasFlag(rest, "--json")) json = true;
551
+ if (hasFlag(rest, "--json")) { json = true; jsonErrors = true; }
518
552
  const message = rest.join(" ").trim();
519
553
  if (!ref || !message) throw new StoreError("gbot send <bot-or-group> <message...>");
520
554
  const out = await backend.send(ref, message);
@@ -528,9 +562,7 @@ async function main(argv) {
528
562
  if (cmd === "thread" || cmd === "chat") {
529
563
  const ref = sub;
530
564
  if (!ref) throw new StoreError("gbot thread <bot-or-group> [--limit N] [--root MESSAGE_ID] [--full]");
531
- if (hasFlag(rest, "--json")) json = true;
532
- const full = rest.includes("--full");
533
- if (full) rest.splice(rest.indexOf("--full"), 1);
565
+ const full = hasFlag(rest, "--full");
534
566
  const limitRaw = takeFlag(rest, "--limit");
535
567
  const rootId = takeFlag(rest, "--root");
536
568
  const limit = limitRaw ? Number(limitRaw) : 40;
@@ -173,10 +173,15 @@ export function connectCodexAppServer(path, { timeoutMs = 15000 } = {}) {
173
173
  let fragOpcode = null;
174
174
  let fragParts = [];
175
175
  let fragBytes = 0;
176
+ // Absolute handshake deadline: socket timeouts reset on any bytes, so trickled
177
+ // headers must not extend this. Per-request timers stay absolute after upgrade.
178
+ const handshakeTimer = setTimeout(() => failAll(new Error("Timed out connecting to Codex app-server at " + path)), timeoutMs);
179
+ if (typeof handshakeTimer.unref === "function") handshakeTimer.unref();
176
180
 
177
181
  const failAll = (err) => {
178
182
  if (closed) return;
179
183
  closed = true;
184
+ clearTimeout(handshakeTimer);
180
185
  if (err && err.delivery == null) err.delivery = pending.size ? "unknown" : "rejected";
181
186
  for (const { reject: rej } of pending.values()) rej(err);
182
187
  pending.clear();
@@ -212,15 +217,20 @@ export function connectCodexAppServer(path, { timeoutMs = 15000 } = {}) {
212
217
  close() {
213
218
  if (closed) return;
214
219
  closed = true;
220
+ clearTimeout(handshakeTimer);
215
221
  const err = new Error("Codex client closed");
216
222
  err.delivery = pending.size ? "unknown" : "rejected";
217
223
  for (const { reject: rej } of pending.values()) rej(err);
218
224
  pending.clear();
219
- if (!socket.destroyed) {
220
- if (upgraded) write(0x8, Buffer.from([0x03, 0xe8]));
221
- socket.end();
222
- socket.unref();
223
- }
225
+ // Best-effort close frame, then guaranteed destruction so no path leaks the socket.
226
+ if (!socket.destroyed && upgraded) write(0x8, Buffer.from([0x03, 0xe8]));
227
+ if (socket.destroyed) return;
228
+ const forceDestroy = setTimeout(() => { try { socket.destroy(); } catch { /* already gone */ } }, 1000);
229
+ if (typeof forceDestroy.unref === "function") forceDestroy.unref();
230
+ socket.end(() => {
231
+ clearTimeout(forceDestroy);
232
+ try { socket.destroy(); } catch { /* already gone */ }
233
+ });
224
234
  },
225
235
  };
226
236
 
@@ -256,10 +266,13 @@ export function connectCodexAppServer(path, { timeoutMs = 15000 } = {}) {
256
266
  failAll(new Error("Codex app-server sent an unreadable message: " + err.message));
257
267
  return;
258
268
  }
269
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) {
270
+ failAll(new Error("Codex app-server sent a malformed message"));
271
+ return;
272
+ }
259
273
  onMessage(msg);
260
274
  };
261
275
 
262
- socket.setTimeout(timeoutMs, () => failAll(new Error("Timed out connecting to Codex app-server at " + path)));
263
276
  socket.once("error", (err) => failAll(new Error("Could not connect to Codex app-server at " + path + ": " + err.message)));
264
277
  socket.once("close", () => failAll(new Error("Codex app-server closed the connection")));
265
278
  socket.once("connect", () => socket.write(upgradeRequest(key)));
@@ -271,11 +284,13 @@ export function connectCodexAppServer(path, { timeoutMs = 15000 } = {}) {
271
284
  if (buf.length > WS_MAX_HEADER_BYTES) failAll(new Error("Codex app-server handshake headers exceed " + WS_MAX_HEADER_BYTES + " bytes"));
272
285
  return;
273
286
  }
287
+ // Terminated headers hit the cap too; size is checked before any decoding.
288
+ if (end > WS_MAX_HEADER_BYTES) return failAll(new Error("Codex app-server handshake headers exceed " + WS_MAX_HEADER_BYTES + " bytes"));
274
289
  const head = buf.subarray(0, end).toString();
275
290
  buf = buf.subarray(end + 4);
276
291
  if (!validateUpgradeHead(head, key)) return failAll(new Error("Codex app-server refused the WebSocket upgrade: " + head.split("\r\n")[0]));
277
292
  upgraded = true;
278
- socket.setTimeout(0);
293
+ clearTimeout(handshakeTimer);
279
294
  resolve(client);
280
295
  }
281
296
  for (;;) {
@@ -290,6 +305,7 @@ export function connectCodexAppServer(path, { timeoutMs = 15000 } = {}) {
290
305
  return;
291
306
  }
292
307
  buf = frame.rest;
308
+ if (frame.payload.length > WS_MAX_MESSAGE_BYTES) return failAll(new Error("Codex app-server frame exceeds " + WS_MAX_MESSAGE_BYTES + " bytes"));
293
309
  if (frame.masked) return failProtocol("server frames must not be masked");
294
310
  if (frame.opcode >= 0x8) {
295
311
  if (!frame.fin || frame.payload.length > 125) return failProtocol("bad control frame");
package/src/gateway.js CHANGED
@@ -82,11 +82,32 @@ export function hasGatewayAuth() {
82
82
  return Boolean(gatewayOverride() || accessTokenFromEnv() || hasGrokBotGatewaySession());
83
83
  }
84
84
 
85
- async function readJson(res) {
86
- const text = await res.text();
87
- if (text.length > GATEWAY_MAX_RESPONSE_BYTES) {
88
- throw new GatewayError("Gateway response too large (" + text.length + " bytes, limit " + GATEWAY_MAX_RESPONSE_BYTES + ")");
85
+ async function readTextCapped(res, maxBytes) {
86
+ if (!res.body || typeof res.body.getReader !== "function") {
87
+ const text = await res.text();
88
+ if (Buffer.byteLength(text, "utf8") > maxBytes) {
89
+ throw new GatewayError("Gateway response too large (over " + maxBytes + " bytes)");
90
+ }
91
+ return text;
92
+ }
93
+ const reader = res.body.getReader();
94
+ const chunks = [];
95
+ let bytes = 0;
96
+ for (;;) {
97
+ const { done, value } = await reader.read();
98
+ if (done) break;
99
+ bytes += value.byteLength ?? value.length;
100
+ if (bytes > maxBytes) {
101
+ try { await reader.cancel(); } catch { /* already closed */ }
102
+ throw new GatewayError("Gateway response too large (over " + maxBytes + " bytes)");
103
+ }
104
+ chunks.push(value);
89
105
  }
106
+ return Buffer.concat(chunks.map((c) => Buffer.from(c.buffer ?? c, c.byteOffset ?? 0, c.byteLength ?? c.length))).toString("utf8");
107
+ }
108
+
109
+ async function readJson(res) {
110
+ const text = await readTextCapped(res, GATEWAY_MAX_RESPONSE_BYTES);
90
111
  if (!text) return {};
91
112
  try {
92
113
  return JSON.parse(text);
@@ -343,8 +364,10 @@ export async function sendPrompt(session, ref, prompt, extra = {}) {
343
364
  }
344
365
  throw err;
345
366
  }
346
- const messageId = data && typeof data.messageId === "string" ? data.messageId : null;
347
- return { target: rec, result: data, delivery: "accepted", ...(messageId ? { messageId } : {}) };
367
+ const messageId = data && typeof data === "object" && typeof data.messageId === "string" ? data.messageId : null;
368
+ // Only a confirmed receipt counts as accepted; anything else is unknown, never a silent accept.
369
+ // ponytail: full send/execution correlation envelope stays in #37.
370
+ return { target: rec, result: data && typeof data === "object" ? data : {}, delivery: messageId ? "accepted" : "unknown", ...(messageId ? { messageId } : {}) };
348
371
  }
349
372
 
350
373
  export async function getTranscriptTail(session, ref, limit = 40) {