grok-bot-cli 0.2.3 → 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.
@@ -0,0 +1,496 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { statSync } from "node:fs";
3
+ import { createConnection } from "node:net";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { spawnSync } from "node:child_process";
7
+ import { createRequire } from "node:module";
8
+
9
+ // Method and param names below come from `codex app-server generate-json-schema`
10
+ // of this Codex release. Newer daemons usually keep them; `gbot codex status`
11
+ // reports the running daemon's version next to this one.
12
+ export const PINNED_CODEX_VERSION = "0.154.0";
13
+ export const UPSTREAM_DESKTOP_ISSUES = [
14
+ "https://github.com/openai/codex/issues/41014",
15
+ "https://github.com/openai/codex/issues/41112",
16
+ ];
17
+
18
+ const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
19
+ // Transport budgets: fail fast instead of buffering unbounded attacker-controlled bytes.
20
+ // ponytail: raise these only with streaming/pagination support; the app-server sends small JSON-RPC frames.
21
+ export const WS_MAX_HEADER_BYTES = 16 * 1024;
22
+ export const WS_MAX_MESSAGE_BYTES = 4 * 1024 * 1024;
23
+ export const WS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
24
+ const textDecoder = new TextDecoder("utf-8", { fatal: true });
25
+ const pkg = createRequire(import.meta.url)("../package.json");
26
+
27
+ export function codexSocketPath(env = process.env) {
28
+ const home = env.CODEX_HOME || join(homedir(), ".codex");
29
+ return join(home, "app-server-control", "app-server-control.sock");
30
+ }
31
+
32
+ export function socketPresent(path) {
33
+ try {
34
+ return statSync(path).isSocket();
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ export function unreachableMessage(path) {
41
+ return [
42
+ "No Codex app-server control socket at " + path + ".",
43
+ "Either no daemon is running (start one with `codex app-server daemon start`),",
44
+ "or ChatGPT Desktop is running a private stdio app-server that external clients cannot reach",
45
+ "(" + UPSTREAM_DESKTOP_ISSUES.join(", ") + ").",
46
+ "gbot codex targets daemon-managed threads only.",
47
+ ].join("\n");
48
+ }
49
+
50
+ export function windowsUnsupportedMessage() {
51
+ return [
52
+ "gbot codex does not support native Windows yet.",
53
+ "Codex's control socket is AF_UNIX; this CLI's Node client only dials Unix domain sockets.",
54
+ "Use WSL, Linux, or macOS (or a future stdio proxy path).",
55
+ ].join("\n");
56
+ }
57
+
58
+ export function encodeFrame(opcode, payload, mask) {
59
+ const len = payload.length;
60
+ const head = Buffer.alloc(len < 126 ? 2 : len < 65536 ? 4 : 10);
61
+ head[0] = 0x80 | opcode;
62
+ if (len < 126) head[1] = len;
63
+ else if (len < 65536) {
64
+ head[1] = 126;
65
+ head.writeUInt16BE(len, 2);
66
+ } else {
67
+ head[1] = 127;
68
+ head.writeBigUInt64BE(BigInt(len), 2);
69
+ }
70
+ if (!mask) return Buffer.concat([head, payload]);
71
+ head[1] |= 0x80;
72
+ const body = Buffer.from(payload);
73
+ for (let i = 0; i < body.length; i++) body[i] ^= mask[i & 3];
74
+ return Buffer.concat([head, mask, body]);
75
+ }
76
+
77
+ export function decodeFrame(buf) {
78
+ if (buf.length < 2) return null;
79
+ const fin = (buf[0] & 0x80) !== 0;
80
+ const opcode = buf[0] & 0x0f;
81
+ const masked = (buf[1] & 0x80) !== 0;
82
+ let len = buf[1] & 0x7f;
83
+ let offset = 2;
84
+ if (len === 126) {
85
+ if (buf.length < 4) return null;
86
+ len = buf.readUInt16BE(2);
87
+ offset = 4;
88
+ } else if (len === 127) {
89
+ if (buf.length < 10) return null;
90
+ len = Number(buf.readBigUInt64BE(2));
91
+ offset = 10;
92
+ }
93
+ const mask = masked ? buf.subarray(offset, offset + 4) : null;
94
+ if (masked) offset += 4;
95
+ if (buf.length < offset + len) return null;
96
+ const payload = Buffer.from(buf.subarray(offset, offset + len));
97
+ if (mask) for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3];
98
+ return { fin, opcode, masked, payload, rest: buf.subarray(offset + len) };
99
+ }
100
+
101
+ /** Claimed frame length without consuming; null when the length prefix is incomplete. */
102
+ function peekFrameLength(buf) {
103
+ if (buf.length < 2) return null;
104
+ const marker = buf[1] & 0x7f;
105
+ if (marker < 126) return marker;
106
+ if (marker === 126) {
107
+ if (buf.length < 4) return null;
108
+ return buf.readUInt16BE(2);
109
+ }
110
+ if (buf.length < 10) return null;
111
+ const big = buf.readBigUInt64BE(2);
112
+ return big > BigInt(Number.MAX_SAFE_INTEGER) ? Infinity : Number(big);
113
+ }
114
+
115
+ function validateUpgradeHead(head, key) {
116
+ const lines = head.split("\r\n");
117
+ if (!/^HTTP\/1\.1 101/.test(lines[0])) return false;
118
+ const headers = new Map();
119
+ for (const line of lines.slice(1)) {
120
+ const i = line.indexOf(":");
121
+ if (i === -1) return false;
122
+ headers.set(line.slice(0, i).trim().toLowerCase(), line.slice(i + 1).trim());
123
+ }
124
+ return headers.get("sec-websocket-accept") === websocketAccept(key)
125
+ && (headers.get("upgrade") || "").toLowerCase() === "websocket"
126
+ && (headers.get("connection") || "").toLowerCase().includes("upgrade");
127
+ }
128
+
129
+ function upgradeRequest(key) {
130
+ return "GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
131
+ + "Sec-WebSocket-Key: " + key + "\r\nSec-WebSocket-Version: 13\r\n\r\n";
132
+ }
133
+
134
+ export function websocketAccept(key) {
135
+ return createHash("sha1").update(key + WS_GUID).digest("base64");
136
+ }
137
+
138
+ export class CodexRpcError extends Error {
139
+ constructor(method, error) {
140
+ super("Codex app-server rejected " + method + ": " + (error && error.message ? error.message : JSON.stringify(error)));
141
+ this.name = "CodexRpcError";
142
+ this.method = method;
143
+ this.rpc = error;
144
+ }
145
+ }
146
+
147
+ export class CodexSendError extends Error {
148
+ constructor(message, { delivery, threadId, turnId, refused } = {}) {
149
+ super(message);
150
+ this.name = "CodexSendError";
151
+ this.delivery = delivery;
152
+ if (threadId !== undefined) this.threadId = threadId;
153
+ if (turnId !== undefined) this.turnId = turnId;
154
+ if (refused !== undefined) this.refused = refused;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Open a JSON-RPC session to the app-server over its Unix socket (WebSocket framing).
160
+ * Server-initiated requests (approvals, user input) are refused with a JSON-RPC error
161
+ * and recorded in `refused`; gbot never approves on the user's behalf.
162
+ */
163
+ export function connectCodexAppServer(path, { timeoutMs = 15000 } = {}) {
164
+ return new Promise((resolve, reject) => {
165
+ const socket = createConnection({ path });
166
+ const key = randomBytes(16).toString("base64");
167
+ const pending = new Map();
168
+ const refused = [];
169
+ let nextId = 1;
170
+ let buf = Buffer.alloc(0);
171
+ let upgraded = false;
172
+ let closed = false;
173
+ let fragOpcode = null;
174
+ let fragParts = [];
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();
180
+
181
+ const failAll = (err) => {
182
+ if (closed) return;
183
+ closed = true;
184
+ clearTimeout(handshakeTimer);
185
+ if (err && err.delivery == null) err.delivery = pending.size ? "unknown" : "rejected";
186
+ for (const { reject: rej } of pending.values()) rej(err);
187
+ pending.clear();
188
+ try { socket.destroy(); } catch { /* already gone */ }
189
+ reject(err);
190
+ };
191
+ const failProtocol = (detail) => failAll(new Error("Codex app-server violated the WebSocket protocol: " + detail));
192
+ const write = (opcode, payload) => {
193
+ if (!socket.destroyed) socket.write(encodeFrame(opcode, payload, randomBytes(4)));
194
+ };
195
+ const sendJson = (obj) => write(0x1, Buffer.from(JSON.stringify(obj)));
196
+
197
+ const client = {
198
+ refused,
199
+ request(method, params) {
200
+ const id = nextId++;
201
+ return new Promise((res, rej) => {
202
+ const timer = setTimeout(() => {
203
+ pending.delete(id);
204
+ rej(new Error("Codex app-server did not answer " + method + " within " + timeoutMs + "ms"));
205
+ }, timeoutMs);
206
+ pending.set(id, {
207
+ method,
208
+ resolve: (v) => { clearTimeout(timer); res(v); },
209
+ reject: (e) => { clearTimeout(timer); rej(e); },
210
+ });
211
+ sendJson({ jsonrpc: "2.0", id, method, params });
212
+ });
213
+ },
214
+ notify(method, params) {
215
+ sendJson({ jsonrpc: "2.0", method, params });
216
+ },
217
+ close() {
218
+ if (closed) return;
219
+ closed = true;
220
+ clearTimeout(handshakeTimer);
221
+ const err = new Error("Codex client closed");
222
+ err.delivery = pending.size ? "unknown" : "rejected";
223
+ for (const { reject: rej } of pending.values()) rej(err);
224
+ pending.clear();
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
+ });
234
+ },
235
+ };
236
+
237
+ const onMessage = (msg) => {
238
+ if (msg.id != null && msg.method) {
239
+ refused.push({ id: msg.id, method: msg.method, params: msg.params });
240
+ sendJson({
241
+ jsonrpc: "2.0",
242
+ id: msg.id,
243
+ error: { code: -32601, message: "gbot codex does not answer " + msg.method + "; configure approval_policy on the daemon" },
244
+ });
245
+ return;
246
+ }
247
+ if (msg.id == null || !pending.has(msg.id)) return;
248
+ const entry = pending.get(msg.id);
249
+ pending.delete(msg.id);
250
+ if (msg.error) entry.reject(new CodexRpcError(entry.method, msg.error));
251
+ else entry.resolve(msg.result);
252
+ };
253
+
254
+ const onText = (payload) => {
255
+ let text;
256
+ try {
257
+ text = textDecoder.decode(payload);
258
+ } catch {
259
+ failAll(new Error("Codex app-server sent a non-UTF-8 text message"));
260
+ return;
261
+ }
262
+ let msg;
263
+ try {
264
+ msg = JSON.parse(text);
265
+ } catch (err) {
266
+ failAll(new Error("Codex app-server sent an unreadable message: " + err.message));
267
+ return;
268
+ }
269
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) {
270
+ failAll(new Error("Codex app-server sent a malformed message"));
271
+ return;
272
+ }
273
+ onMessage(msg);
274
+ };
275
+
276
+ socket.once("error", (err) => failAll(new Error("Could not connect to Codex app-server at " + path + ": " + err.message)));
277
+ socket.once("close", () => failAll(new Error("Codex app-server closed the connection")));
278
+ socket.once("connect", () => socket.write(upgradeRequest(key)));
279
+ socket.on("data", (chunk) => {
280
+ buf = Buffer.concat([buf, chunk]);
281
+ if (!upgraded) {
282
+ const end = buf.indexOf("\r\n\r\n");
283
+ if (end === -1) {
284
+ if (buf.length > WS_MAX_HEADER_BYTES) failAll(new Error("Codex app-server handshake headers exceed " + WS_MAX_HEADER_BYTES + " bytes"));
285
+ return;
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"));
289
+ const head = buf.subarray(0, end).toString();
290
+ buf = buf.subarray(end + 4);
291
+ if (!validateUpgradeHead(head, key)) return failAll(new Error("Codex app-server refused the WebSocket upgrade: " + head.split("\r\n")[0]));
292
+ upgraded = true;
293
+ clearTimeout(handshakeTimer);
294
+ resolve(client);
295
+ }
296
+ for (;;) {
297
+ const frame = decodeFrame(buf);
298
+ if (!frame) {
299
+ const claimed = peekFrameLength(buf);
300
+ if (claimed != null && claimed > WS_MAX_MESSAGE_BYTES) {
301
+ failAll(new Error("Codex app-server frame exceeds " + WS_MAX_MESSAGE_BYTES + " bytes"));
302
+ } else if (buf.length > WS_MAX_BUFFER_BYTES) {
303
+ failAll(new Error("Codex app-server buffer exceeds " + WS_MAX_BUFFER_BYTES + " bytes"));
304
+ }
305
+ return;
306
+ }
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"));
309
+ if (frame.masked) return failProtocol("server frames must not be masked");
310
+ if (frame.opcode >= 0x8) {
311
+ if (!frame.fin || frame.payload.length > 125) return failProtocol("bad control frame");
312
+ if (frame.opcode === 0x9) write(0xa, frame.payload);
313
+ else if (frame.opcode === 0x8) {
314
+ write(0x8, frame.payload);
315
+ socket.end();
316
+ failAll(new Error("Codex app-server closed the connection"));
317
+ }
318
+ continue; // pong and other control frames carry nothing for us
319
+ }
320
+ if (frame.opcode === 0x0) {
321
+ if (fragOpcode == null) return failProtocol("continuation with nothing to continue");
322
+ fragParts.push(frame.payload);
323
+ fragBytes += frame.payload.length;
324
+ if (fragBytes > WS_MAX_MESSAGE_BYTES) return failAll(new Error("Codex app-server message exceeds " + WS_MAX_MESSAGE_BYTES + " bytes"));
325
+ if (!frame.fin) continue;
326
+ const whole = Buffer.concat(fragParts, fragBytes);
327
+ const opcode = fragOpcode;
328
+ fragOpcode = null;
329
+ fragParts = [];
330
+ fragBytes = 0;
331
+ // ponytail: binary frames are unused by the app-server; only text is delivered.
332
+ if (opcode === 0x1) onText(whole);
333
+ continue;
334
+ }
335
+ if (frame.opcode === 0x1 || frame.opcode === 0x2) {
336
+ if (fragOpcode != null) return failProtocol("new message before finishing fragments");
337
+ if (!frame.fin) {
338
+ fragOpcode = frame.opcode;
339
+ fragParts = [frame.payload];
340
+ fragBytes = frame.payload.length;
341
+ continue;
342
+ }
343
+ // ponytail: binary frames are unused by the app-server; only text is delivered.
344
+ if (frame.opcode === 0x1) onText(frame.payload);
345
+ continue;
346
+ }
347
+ return failProtocol("unknown opcode " + frame.opcode);
348
+ }
349
+ });
350
+ });
351
+ }
352
+
353
+ function appServerVersion(initResult) {
354
+ const ua = initResult && typeof initResult.userAgent === "string" ? initResult.userAgent : "";
355
+ const m = /^[^/\s]+\/(\S+)/.exec(ua);
356
+ return m ? m[1] : null;
357
+ }
358
+
359
+ async function openSession(env = process.env) {
360
+ if (process.platform === "win32") throw new Error(windowsUnsupportedMessage());
361
+ const path = codexSocketPath(env);
362
+ if (!socketPresent(path)) throw new Error(unreachableMessage(path));
363
+ const client = await connectCodexAppServer(path);
364
+ let init;
365
+ try {
366
+ init = await client.request("initialize", { clientInfo: { name: "gbot", version: pkg.version } });
367
+ } catch (err) {
368
+ client.close();
369
+ throw err;
370
+ }
371
+ client.notify("initialized");
372
+ return { client, path, init };
373
+ }
374
+
375
+ export function localCodexVersion() {
376
+ const out = spawnSync("codex", ["--version"], { encoding: "utf8" });
377
+ const m = out.status === 0 ? /(\d+\.\d+\.\d+\S*)/.exec(out.stdout) : null;
378
+ return m ? m[1] : null;
379
+ }
380
+
381
+ export async function codexStatus(env = process.env) {
382
+ const path = codexSocketPath(env);
383
+ const base = { socketPath: path, pinnedVersion: PINNED_CODEX_VERSION, cliVersion: localCodexVersion() };
384
+ if (process.platform === "win32") {
385
+ return { ...base, reachable: false, mode: "windows-unsupported", message: windowsUnsupportedMessage() };
386
+ }
387
+ if (!socketPresent(path)) {
388
+ return { ...base, reachable: false, mode: "socket-absent", message: unreachableMessage(path) };
389
+ }
390
+ const { client, init } = await openSession(env);
391
+ client.close();
392
+ const daemonVersion = appServerVersion(init);
393
+ return {
394
+ ...base,
395
+ reachable: true,
396
+ mode: "daemon",
397
+ daemonVersion,
398
+ codexHome: init.codexHome ?? null,
399
+ versionMismatch: Boolean(base.cliVersion && daemonVersion && base.cliVersion !== daemonVersion),
400
+ };
401
+ }
402
+
403
+ export function summarizeThread(t) {
404
+ return {
405
+ id: t.id,
406
+ status: t.status && t.status.type ? t.status.type : "unknown",
407
+ name: t.name ?? null,
408
+ preview: t.preview ?? "",
409
+ cwd: t.cwd ?? null,
410
+ source: t.source ?? null,
411
+ updatedAt: t.updatedAt ?? null,
412
+ };
413
+ }
414
+
415
+ export async function listCodexThreads({ limit = 20, env = process.env } = {}) {
416
+ const { client } = await openSession(env);
417
+ try {
418
+ // The default listing rescans every rollout file to repair metadata (26 s on a busy machine);
419
+ // the state DB already holds what we print.
420
+ const out = await client.request("thread/list", { limit, useStateDbOnly: true });
421
+ return { threads: out.data.map(summarizeThread), nextCursor: out.nextCursor ?? null };
422
+ } finally {
423
+ client.close();
424
+ }
425
+ }
426
+
427
+ function explainSendError(err, threadId) {
428
+ if (!(err instanceof CodexRpcError)) return err;
429
+ const msg = String(err.rpc && err.rpc.message || "");
430
+ if (/no rollout found|thread not found/i.test(msg)) {
431
+ return new Error("Unknown Codex thread " + threadId + ". Run `gbot codex list-threads` to see reachable threads.");
432
+ }
433
+ if (/active writer/i.test(msg)) {
434
+ return new Error("Codex thread " + threadId + " is open in another client (VS Code, TUI, or Desktop), which owns its turns. Close it there first.");
435
+ }
436
+ return err;
437
+ }
438
+
439
+ export async function sendToCodexThread(threadId, text, env = process.env) {
440
+ const { client } = await openSession(env);
441
+ try {
442
+ let resumed;
443
+ try {
444
+ resumed = await client.request("thread/resume", { threadId, excludeTurns: true });
445
+ } catch (err) {
446
+ if (err instanceof CodexSendError) throw err;
447
+ throw new CodexSendError(explainSendError(err, threadId).message, {
448
+ delivery: err instanceof CodexRpcError ? "rejected" : (err && err.delivery) || "unknown",
449
+ threadId,
450
+ });
451
+ }
452
+ // Scope refusals to this turn: server requests from earlier calls belong to another context.
453
+ const seenRefused = client.refused.length;
454
+ let turn;
455
+ try {
456
+ turn = await client.request("turn/start", { threadId, input: [{ type: "text", text }] });
457
+ } catch (err) {
458
+ if (err instanceof CodexSendError) throw err;
459
+ const delivery = err instanceof CodexRpcError ? "rejected" : (err && err.delivery) || "unknown";
460
+ const detail = err instanceof CodexRpcError
461
+ ? err.message
462
+ : "Lost the Codex turn/start response for thread " + threadId + ": " + ((err && err.message) || err)
463
+ + ". Delivery is unknown; check the thread before resending.";
464
+ // ponytail: no blind retry here; a stable receipt/correlation envelope is issue #37.
465
+ throw new CodexSendError(detail, { delivery, threadId });
466
+ }
467
+ const turnId = turn && turn.turn && typeof turn.turn.id === "string" && turn.turn.id ? turn.turn.id : null;
468
+ if (!turnId) {
469
+ throw new CodexSendError(
470
+ "Codex app-server sent a malformed turn/start acknowledgment for thread " + threadId + ". Delivery is unknown; check the thread before resending.",
471
+ { delivery: "unknown", threadId },
472
+ );
473
+ }
474
+ const freshRefused = client.refused.slice(seenRefused)
475
+ .filter((r) => !r.params || r.params.threadId == null || r.params.threadId === threadId);
476
+ if (freshRefused.length) {
477
+ const methods = freshRefused.map((r) => r.method).join(", ");
478
+ throw new CodexSendError(
479
+ "Turn " + turnId + " started on thread " + threadId + " but Codex asked for " + methods + ", which gbot refused. "
480
+ + "Answer it in a Codex client, or set `approval_policy = \"never\"` in the daemon's config.toml for unattended sends.",
481
+ { delivery: "accepted", threadId, turnId, refused: freshRefused.map((r) => r.method) },
482
+ );
483
+ }
484
+ return {
485
+ delivery: "accepted",
486
+ threadId: resumed.thread.id,
487
+ turnId,
488
+ turnStatus: turn.turn.status,
489
+ model: resumed.model,
490
+ cwd: resumed.cwd,
491
+ approvalPolicy: resumed.approvalPolicy,
492
+ };
493
+ } finally {
494
+ client.close();
495
+ }
496
+ }