grok-bot-cli 0.2.2 → 0.3.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/README.md +100 -1
- package/package.json +3 -3
- package/src/app-session.js +127 -16
- package/src/cli.js +176 -30
- package/src/codex-bridge.js +480 -0
- package/src/gateway.js +79 -15
- package/src/history.js +83 -0
- package/src/transcript.js +50 -0
- package/src/url-policy.js +161 -0
|
@@ -0,0 +1,480 @@
|
|
|
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
|
+
|
|
177
|
+
const failAll = (err) => {
|
|
178
|
+
if (closed) return;
|
|
179
|
+
closed = true;
|
|
180
|
+
if (err && err.delivery == null) err.delivery = pending.size ? "unknown" : "rejected";
|
|
181
|
+
for (const { reject: rej } of pending.values()) rej(err);
|
|
182
|
+
pending.clear();
|
|
183
|
+
try { socket.destroy(); } catch { /* already gone */ }
|
|
184
|
+
reject(err);
|
|
185
|
+
};
|
|
186
|
+
const failProtocol = (detail) => failAll(new Error("Codex app-server violated the WebSocket protocol: " + detail));
|
|
187
|
+
const write = (opcode, payload) => {
|
|
188
|
+
if (!socket.destroyed) socket.write(encodeFrame(opcode, payload, randomBytes(4)));
|
|
189
|
+
};
|
|
190
|
+
const sendJson = (obj) => write(0x1, Buffer.from(JSON.stringify(obj)));
|
|
191
|
+
|
|
192
|
+
const client = {
|
|
193
|
+
refused,
|
|
194
|
+
request(method, params) {
|
|
195
|
+
const id = nextId++;
|
|
196
|
+
return new Promise((res, rej) => {
|
|
197
|
+
const timer = setTimeout(() => {
|
|
198
|
+
pending.delete(id);
|
|
199
|
+
rej(new Error("Codex app-server did not answer " + method + " within " + timeoutMs + "ms"));
|
|
200
|
+
}, timeoutMs);
|
|
201
|
+
pending.set(id, {
|
|
202
|
+
method,
|
|
203
|
+
resolve: (v) => { clearTimeout(timer); res(v); },
|
|
204
|
+
reject: (e) => { clearTimeout(timer); rej(e); },
|
|
205
|
+
});
|
|
206
|
+
sendJson({ jsonrpc: "2.0", id, method, params });
|
|
207
|
+
});
|
|
208
|
+
},
|
|
209
|
+
notify(method, params) {
|
|
210
|
+
sendJson({ jsonrpc: "2.0", method, params });
|
|
211
|
+
},
|
|
212
|
+
close() {
|
|
213
|
+
if (closed) return;
|
|
214
|
+
closed = true;
|
|
215
|
+
const err = new Error("Codex client closed");
|
|
216
|
+
err.delivery = pending.size ? "unknown" : "rejected";
|
|
217
|
+
for (const { reject: rej } of pending.values()) rej(err);
|
|
218
|
+
pending.clear();
|
|
219
|
+
if (!socket.destroyed) {
|
|
220
|
+
if (upgraded) write(0x8, Buffer.from([0x03, 0xe8]));
|
|
221
|
+
socket.end();
|
|
222
|
+
socket.unref();
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const onMessage = (msg) => {
|
|
228
|
+
if (msg.id != null && msg.method) {
|
|
229
|
+
refused.push({ id: msg.id, method: msg.method, params: msg.params });
|
|
230
|
+
sendJson({
|
|
231
|
+
jsonrpc: "2.0",
|
|
232
|
+
id: msg.id,
|
|
233
|
+
error: { code: -32601, message: "gbot codex does not answer " + msg.method + "; configure approval_policy on the daemon" },
|
|
234
|
+
});
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (msg.id == null || !pending.has(msg.id)) return;
|
|
238
|
+
const entry = pending.get(msg.id);
|
|
239
|
+
pending.delete(msg.id);
|
|
240
|
+
if (msg.error) entry.reject(new CodexRpcError(entry.method, msg.error));
|
|
241
|
+
else entry.resolve(msg.result);
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const onText = (payload) => {
|
|
245
|
+
let text;
|
|
246
|
+
try {
|
|
247
|
+
text = textDecoder.decode(payload);
|
|
248
|
+
} catch {
|
|
249
|
+
failAll(new Error("Codex app-server sent a non-UTF-8 text message"));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
let msg;
|
|
253
|
+
try {
|
|
254
|
+
msg = JSON.parse(text);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
failAll(new Error("Codex app-server sent an unreadable message: " + err.message));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
onMessage(msg);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
socket.setTimeout(timeoutMs, () => failAll(new Error("Timed out connecting to Codex app-server at " + path)));
|
|
263
|
+
socket.once("error", (err) => failAll(new Error("Could not connect to Codex app-server at " + path + ": " + err.message)));
|
|
264
|
+
socket.once("close", () => failAll(new Error("Codex app-server closed the connection")));
|
|
265
|
+
socket.once("connect", () => socket.write(upgradeRequest(key)));
|
|
266
|
+
socket.on("data", (chunk) => {
|
|
267
|
+
buf = Buffer.concat([buf, chunk]);
|
|
268
|
+
if (!upgraded) {
|
|
269
|
+
const end = buf.indexOf("\r\n\r\n");
|
|
270
|
+
if (end === -1) {
|
|
271
|
+
if (buf.length > WS_MAX_HEADER_BYTES) failAll(new Error("Codex app-server handshake headers exceed " + WS_MAX_HEADER_BYTES + " bytes"));
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const head = buf.subarray(0, end).toString();
|
|
275
|
+
buf = buf.subarray(end + 4);
|
|
276
|
+
if (!validateUpgradeHead(head, key)) return failAll(new Error("Codex app-server refused the WebSocket upgrade: " + head.split("\r\n")[0]));
|
|
277
|
+
upgraded = true;
|
|
278
|
+
socket.setTimeout(0);
|
|
279
|
+
resolve(client);
|
|
280
|
+
}
|
|
281
|
+
for (;;) {
|
|
282
|
+
const frame = decodeFrame(buf);
|
|
283
|
+
if (!frame) {
|
|
284
|
+
const claimed = peekFrameLength(buf);
|
|
285
|
+
if (claimed != null && claimed > WS_MAX_MESSAGE_BYTES) {
|
|
286
|
+
failAll(new Error("Codex app-server frame exceeds " + WS_MAX_MESSAGE_BYTES + " bytes"));
|
|
287
|
+
} else if (buf.length > WS_MAX_BUFFER_BYTES) {
|
|
288
|
+
failAll(new Error("Codex app-server buffer exceeds " + WS_MAX_BUFFER_BYTES + " bytes"));
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
buf = frame.rest;
|
|
293
|
+
if (frame.masked) return failProtocol("server frames must not be masked");
|
|
294
|
+
if (frame.opcode >= 0x8) {
|
|
295
|
+
if (!frame.fin || frame.payload.length > 125) return failProtocol("bad control frame");
|
|
296
|
+
if (frame.opcode === 0x9) write(0xa, frame.payload);
|
|
297
|
+
else if (frame.opcode === 0x8) {
|
|
298
|
+
write(0x8, frame.payload);
|
|
299
|
+
socket.end();
|
|
300
|
+
failAll(new Error("Codex app-server closed the connection"));
|
|
301
|
+
}
|
|
302
|
+
continue; // pong and other control frames carry nothing for us
|
|
303
|
+
}
|
|
304
|
+
if (frame.opcode === 0x0) {
|
|
305
|
+
if (fragOpcode == null) return failProtocol("continuation with nothing to continue");
|
|
306
|
+
fragParts.push(frame.payload);
|
|
307
|
+
fragBytes += frame.payload.length;
|
|
308
|
+
if (fragBytes > WS_MAX_MESSAGE_BYTES) return failAll(new Error("Codex app-server message exceeds " + WS_MAX_MESSAGE_BYTES + " bytes"));
|
|
309
|
+
if (!frame.fin) continue;
|
|
310
|
+
const whole = Buffer.concat(fragParts, fragBytes);
|
|
311
|
+
const opcode = fragOpcode;
|
|
312
|
+
fragOpcode = null;
|
|
313
|
+
fragParts = [];
|
|
314
|
+
fragBytes = 0;
|
|
315
|
+
// ponytail: binary frames are unused by the app-server; only text is delivered.
|
|
316
|
+
if (opcode === 0x1) onText(whole);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (frame.opcode === 0x1 || frame.opcode === 0x2) {
|
|
320
|
+
if (fragOpcode != null) return failProtocol("new message before finishing fragments");
|
|
321
|
+
if (!frame.fin) {
|
|
322
|
+
fragOpcode = frame.opcode;
|
|
323
|
+
fragParts = [frame.payload];
|
|
324
|
+
fragBytes = frame.payload.length;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
// ponytail: binary frames are unused by the app-server; only text is delivered.
|
|
328
|
+
if (frame.opcode === 0x1) onText(frame.payload);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
return failProtocol("unknown opcode " + frame.opcode);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function appServerVersion(initResult) {
|
|
338
|
+
const ua = initResult && typeof initResult.userAgent === "string" ? initResult.userAgent : "";
|
|
339
|
+
const m = /^[^/\s]+\/(\S+)/.exec(ua);
|
|
340
|
+
return m ? m[1] : null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function openSession(env = process.env) {
|
|
344
|
+
if (process.platform === "win32") throw new Error(windowsUnsupportedMessage());
|
|
345
|
+
const path = codexSocketPath(env);
|
|
346
|
+
if (!socketPresent(path)) throw new Error(unreachableMessage(path));
|
|
347
|
+
const client = await connectCodexAppServer(path);
|
|
348
|
+
let init;
|
|
349
|
+
try {
|
|
350
|
+
init = await client.request("initialize", { clientInfo: { name: "gbot", version: pkg.version } });
|
|
351
|
+
} catch (err) {
|
|
352
|
+
client.close();
|
|
353
|
+
throw err;
|
|
354
|
+
}
|
|
355
|
+
client.notify("initialized");
|
|
356
|
+
return { client, path, init };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function localCodexVersion() {
|
|
360
|
+
const out = spawnSync("codex", ["--version"], { encoding: "utf8" });
|
|
361
|
+
const m = out.status === 0 ? /(\d+\.\d+\.\d+\S*)/.exec(out.stdout) : null;
|
|
362
|
+
return m ? m[1] : null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export async function codexStatus(env = process.env) {
|
|
366
|
+
const path = codexSocketPath(env);
|
|
367
|
+
const base = { socketPath: path, pinnedVersion: PINNED_CODEX_VERSION, cliVersion: localCodexVersion() };
|
|
368
|
+
if (process.platform === "win32") {
|
|
369
|
+
return { ...base, reachable: false, mode: "windows-unsupported", message: windowsUnsupportedMessage() };
|
|
370
|
+
}
|
|
371
|
+
if (!socketPresent(path)) {
|
|
372
|
+
return { ...base, reachable: false, mode: "socket-absent", message: unreachableMessage(path) };
|
|
373
|
+
}
|
|
374
|
+
const { client, init } = await openSession(env);
|
|
375
|
+
client.close();
|
|
376
|
+
const daemonVersion = appServerVersion(init);
|
|
377
|
+
return {
|
|
378
|
+
...base,
|
|
379
|
+
reachable: true,
|
|
380
|
+
mode: "daemon",
|
|
381
|
+
daemonVersion,
|
|
382
|
+
codexHome: init.codexHome ?? null,
|
|
383
|
+
versionMismatch: Boolean(base.cliVersion && daemonVersion && base.cliVersion !== daemonVersion),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function summarizeThread(t) {
|
|
388
|
+
return {
|
|
389
|
+
id: t.id,
|
|
390
|
+
status: t.status && t.status.type ? t.status.type : "unknown",
|
|
391
|
+
name: t.name ?? null,
|
|
392
|
+
preview: t.preview ?? "",
|
|
393
|
+
cwd: t.cwd ?? null,
|
|
394
|
+
source: t.source ?? null,
|
|
395
|
+
updatedAt: t.updatedAt ?? null,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function listCodexThreads({ limit = 20, env = process.env } = {}) {
|
|
400
|
+
const { client } = await openSession(env);
|
|
401
|
+
try {
|
|
402
|
+
// The default listing rescans every rollout file to repair metadata (26 s on a busy machine);
|
|
403
|
+
// the state DB already holds what we print.
|
|
404
|
+
const out = await client.request("thread/list", { limit, useStateDbOnly: true });
|
|
405
|
+
return { threads: out.data.map(summarizeThread), nextCursor: out.nextCursor ?? null };
|
|
406
|
+
} finally {
|
|
407
|
+
client.close();
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function explainSendError(err, threadId) {
|
|
412
|
+
if (!(err instanceof CodexRpcError)) return err;
|
|
413
|
+
const msg = String(err.rpc && err.rpc.message || "");
|
|
414
|
+
if (/no rollout found|thread not found/i.test(msg)) {
|
|
415
|
+
return new Error("Unknown Codex thread " + threadId + ". Run `gbot codex list-threads` to see reachable threads.");
|
|
416
|
+
}
|
|
417
|
+
if (/active writer/i.test(msg)) {
|
|
418
|
+
return new Error("Codex thread " + threadId + " is open in another client (VS Code, TUI, or Desktop), which owns its turns. Close it there first.");
|
|
419
|
+
}
|
|
420
|
+
return err;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export async function sendToCodexThread(threadId, text, env = process.env) {
|
|
424
|
+
const { client } = await openSession(env);
|
|
425
|
+
try {
|
|
426
|
+
let resumed;
|
|
427
|
+
try {
|
|
428
|
+
resumed = await client.request("thread/resume", { threadId, excludeTurns: true });
|
|
429
|
+
} catch (err) {
|
|
430
|
+
if (err instanceof CodexSendError) throw err;
|
|
431
|
+
throw new CodexSendError(explainSendError(err, threadId).message, {
|
|
432
|
+
delivery: err instanceof CodexRpcError ? "rejected" : (err && err.delivery) || "unknown",
|
|
433
|
+
threadId,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
// Scope refusals to this turn: server requests from earlier calls belong to another context.
|
|
437
|
+
const seenRefused = client.refused.length;
|
|
438
|
+
let turn;
|
|
439
|
+
try {
|
|
440
|
+
turn = await client.request("turn/start", { threadId, input: [{ type: "text", text }] });
|
|
441
|
+
} catch (err) {
|
|
442
|
+
if (err instanceof CodexSendError) throw err;
|
|
443
|
+
const delivery = err instanceof CodexRpcError ? "rejected" : (err && err.delivery) || "unknown";
|
|
444
|
+
const detail = err instanceof CodexRpcError
|
|
445
|
+
? err.message
|
|
446
|
+
: "Lost the Codex turn/start response for thread " + threadId + ": " + ((err && err.message) || err)
|
|
447
|
+
+ ". Delivery is unknown; check the thread before resending.";
|
|
448
|
+
// ponytail: no blind retry here; a stable receipt/correlation envelope is issue #37.
|
|
449
|
+
throw new CodexSendError(detail, { delivery, threadId });
|
|
450
|
+
}
|
|
451
|
+
const turnId = turn && turn.turn && typeof turn.turn.id === "string" && turn.turn.id ? turn.turn.id : null;
|
|
452
|
+
if (!turnId) {
|
|
453
|
+
throw new CodexSendError(
|
|
454
|
+
"Codex app-server sent a malformed turn/start acknowledgment for thread " + threadId + ". Delivery is unknown; check the thread before resending.",
|
|
455
|
+
{ delivery: "unknown", threadId },
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
const freshRefused = client.refused.slice(seenRefused)
|
|
459
|
+
.filter((r) => !r.params || r.params.threadId == null || r.params.threadId === threadId);
|
|
460
|
+
if (freshRefused.length) {
|
|
461
|
+
const methods = freshRefused.map((r) => r.method).join(", ");
|
|
462
|
+
throw new CodexSendError(
|
|
463
|
+
"Turn " + turnId + " started on thread " + threadId + " but Codex asked for " + methods + ", which gbot refused. "
|
|
464
|
+
+ "Answer it in a Codex client, or set `approval_policy = \"never\"` in the daemon's config.toml for unattended sends.",
|
|
465
|
+
{ delivery: "accepted", threadId, turnId, refused: freshRefused.map((r) => r.method) },
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
return {
|
|
469
|
+
delivery: "accepted",
|
|
470
|
+
threadId: resumed.thread.id,
|
|
471
|
+
turnId,
|
|
472
|
+
turnStatus: turn.turn.status,
|
|
473
|
+
model: resumed.model,
|
|
474
|
+
cwd: resumed.cwd,
|
|
475
|
+
approvalPolicy: resumed.approvalPolicy,
|
|
476
|
+
};
|
|
477
|
+
} finally {
|
|
478
|
+
client.close();
|
|
479
|
+
}
|
|
480
|
+
}
|
package/src/gateway.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { ensureSandboxHeaders, headersFromEnsureSandbox, headersFromEnv, mergeGatewayHeaders, normalizeHeaderMap, requestHeaders } from "./headers.js";
|
|
3
3
|
import { hasGrokBotGatewaySession, loadGrokBotGatewaySession } from "./app-session.js";
|
|
4
|
-
import { AVATAR_COLORS, AVATAR_SHAPES } from "./store.js";
|
|
4
|
+
import { AVATAR_COLORS, AVATAR_SHAPES, MAX_GROUP_MEMBERS } from "./store.js";
|
|
5
|
+
import { assertAllowedCredentialUrl, redactSecrets } from "./url-policy.js";
|
|
5
6
|
|
|
6
7
|
export class GatewayError extends Error {
|
|
7
8
|
constructor(message, { status, method } = {}) {
|
|
@@ -12,6 +13,10 @@ export class GatewayError extends Error {
|
|
|
12
13
|
}
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
// ponytail: fixed 30 s deadline and buffered byte cap; upgrade path is per-method budgets plus streaming reads.
|
|
17
|
+
export const GATEWAY_TIMEOUT_MS = 30000;
|
|
18
|
+
export const GATEWAY_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
19
|
+
|
|
15
20
|
function backendBase() {
|
|
16
21
|
return (
|
|
17
22
|
process.env.SAND_BACKEND_URL ||
|
|
@@ -45,15 +50,29 @@ function gatewayOverride() {
|
|
|
45
50
|
? "http://127.0.0.1:" + (process.env.SAND_HOST_PORT || "1340")
|
|
46
51
|
: "";
|
|
47
52
|
const url = explicitUrl || localUrl;
|
|
48
|
-
if (url && token)
|
|
53
|
+
if (url && token) {
|
|
54
|
+
return {
|
|
55
|
+
gatewayUrl: assertAllowedCredentialUrl(url.replace(/\/$/, ""), { kind: "gateway" }),
|
|
56
|
+
gatewayToken: token,
|
|
57
|
+
gatewayHeaders: headersFromEnv(),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
49
60
|
return null;
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
function sessionFromApp() {
|
|
53
|
-
|
|
64
|
+
let loaded;
|
|
65
|
+
try {
|
|
66
|
+
loaded = loadGrokBotGatewaySession();
|
|
67
|
+
} catch (error) {
|
|
68
|
+
// Descriptor present but unusable (e.g. Windows Local State missing). Fall
|
|
69
|
+
// through to CURSOR_ACCESS_TOKEN → EnsureSandBox when that token is set.
|
|
70
|
+
if (accessTokenFromEnv()) return null;
|
|
71
|
+
throw error instanceof Error ? new GatewayError(error.message) : error;
|
|
72
|
+
}
|
|
54
73
|
if (!loaded) return null;
|
|
55
74
|
return {
|
|
56
|
-
gatewayUrl: loaded.gatewayUrl,
|
|
75
|
+
gatewayUrl: assertAllowedCredentialUrl(loaded.gatewayUrl, { kind: "gateway" }),
|
|
57
76
|
gatewayToken: loaded.gatewayToken,
|
|
58
77
|
gatewayHeaders: mergeGatewayHeaders(normalizeHeaderMap(loaded.headers), headersFromEnv()),
|
|
59
78
|
};
|
|
@@ -65,6 +84,9 @@ export function hasGatewayAuth() {
|
|
|
65
84
|
|
|
66
85
|
async function readJson(res) {
|
|
67
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 + ")");
|
|
89
|
+
}
|
|
68
90
|
if (!text) return {};
|
|
69
91
|
try {
|
|
70
92
|
return JSON.parse(text);
|
|
@@ -82,23 +104,25 @@ function pick(obj, ...keys) {
|
|
|
82
104
|
}
|
|
83
105
|
|
|
84
106
|
export async function ensureSandbox(accessToken) {
|
|
85
|
-
const url = backendBase() + "/aiserver.v1.GrokBotService/EnsureSandBox";
|
|
107
|
+
const url = assertAllowedCredentialUrl(backendBase(), { kind: "backend" }) + "/aiserver.v1.GrokBotService/EnsureSandBox";
|
|
86
108
|
const res = await fetch(url, {
|
|
87
109
|
method: "POST",
|
|
110
|
+
redirect: "error",
|
|
111
|
+
signal: AbortSignal.timeout(GATEWAY_TIMEOUT_MS),
|
|
88
112
|
headers: ensureSandboxHeaders(accessToken),
|
|
89
113
|
body: "{}",
|
|
90
114
|
});
|
|
91
115
|
const body = await readJson(res);
|
|
92
116
|
if (!res.ok) {
|
|
93
117
|
const detail = body.message || body.error || body.raw || res.statusText;
|
|
94
|
-
throw new GatewayError("EnsureSandBox failed: " + res.status + " " + detail, { status: res.status, method: "EnsureSandBox" });
|
|
118
|
+
throw new GatewayError("EnsureSandBox failed: " + res.status + " " + redactSecrets(detail), { status: res.status, method: "EnsureSandBox" });
|
|
95
119
|
}
|
|
96
120
|
const gatewayUrl = pick(body, "gatewayUrl", "gateway_url");
|
|
97
121
|
const gatewayToken = pick(body, "gatewayToken", "gateway_token");
|
|
98
122
|
if (!gatewayUrl || !gatewayToken) {
|
|
99
123
|
throw new GatewayError("EnsureSandBox returned no gatewayUrl/gatewayToken. Auth may be a dashboard API key (those do not work).");
|
|
100
124
|
}
|
|
101
|
-
return { gatewayUrl: String(gatewayUrl).replace(/\/$/, ""), gatewayToken: String(gatewayToken), gatewayHeaders: mergeGatewayHeaders(headersFromEnsureSandbox(body), headersFromEnv()) };
|
|
125
|
+
return { gatewayUrl: assertAllowedCredentialUrl(String(gatewayUrl).replace(/\/$/, ""), { kind: "gateway" }), gatewayToken: String(gatewayToken), gatewayHeaders: mergeGatewayHeaders(headersFromEnsureSandbox(body), headersFromEnv()) };
|
|
102
126
|
}
|
|
103
127
|
|
|
104
128
|
export async function connectGateway() {
|
|
@@ -114,16 +138,19 @@ export async function connectGateway() {
|
|
|
114
138
|
}
|
|
115
139
|
|
|
116
140
|
export async function gatewayCall(session, method, body = {}) {
|
|
117
|
-
const
|
|
141
|
+
const base = assertAllowedCredentialUrl(session.gatewayUrl, { kind: "gateway" });
|
|
142
|
+
const url = base + "/api/" + method;
|
|
118
143
|
const res = await fetch(url, {
|
|
119
144
|
method: "POST",
|
|
145
|
+
redirect: "error",
|
|
146
|
+
signal: AbortSignal.timeout(GATEWAY_TIMEOUT_MS),
|
|
120
147
|
headers: requestHeaders(session),
|
|
121
148
|
body: JSON.stringify(body),
|
|
122
149
|
});
|
|
123
150
|
const data = await readJson(res);
|
|
124
151
|
if (!res.ok) {
|
|
125
152
|
const detail = data.message || data.error || data.raw || res.statusText;
|
|
126
|
-
throw new GatewayError(method + " failed: " + res.status + " " + String(detail).slice(0, 300), { status: res.status, method });
|
|
153
|
+
throw new GatewayError(method + " failed: " + res.status + " " + redactSecrets(String(detail).slice(0, 300)), { status: res.status, method });
|
|
127
154
|
}
|
|
128
155
|
return data;
|
|
129
156
|
}
|
|
@@ -228,9 +255,27 @@ export async function deleteAgent(session, ref) {
|
|
|
228
255
|
return rec;
|
|
229
256
|
}
|
|
230
257
|
|
|
258
|
+
function normalizeMemberIds(records, memberRefs) {
|
|
259
|
+
const memberIds = new Set();
|
|
260
|
+
for (const ref of memberRefs) {
|
|
261
|
+
const rec = resolveFromList(records, ref);
|
|
262
|
+
if (rec.isGroup) {
|
|
263
|
+
throw new GatewayError(`Cannot add group "${rec.name}" as a member. Nested groups are not allowed.`);
|
|
264
|
+
}
|
|
265
|
+
memberIds.add(rec.id);
|
|
266
|
+
}
|
|
267
|
+
if (memberIds.size === 0) {
|
|
268
|
+
throw new GatewayError("A group needs at least one existing member agent.");
|
|
269
|
+
}
|
|
270
|
+
if (memberIds.size > MAX_GROUP_MEMBERS) {
|
|
271
|
+
throw new GatewayError(`A group can have at most ${MAX_GROUP_MEMBERS} members.`);
|
|
272
|
+
}
|
|
273
|
+
return [...memberIds];
|
|
274
|
+
}
|
|
275
|
+
|
|
231
276
|
export async function createGroup(session, input) {
|
|
232
277
|
const records = await listAgents(session);
|
|
233
|
-
const memberAgentIds = (input.memberIds || [])
|
|
278
|
+
const memberAgentIds = normalizeMemberIds(records, input.memberIds || []);
|
|
234
279
|
const data = await gatewayCall(session, "createGroup", {
|
|
235
280
|
name: input.name,
|
|
236
281
|
description: input.description || "",
|
|
@@ -248,7 +293,8 @@ export async function createGroup(session, input) {
|
|
|
248
293
|
export async function setGroupMembers(session, groupRef, memberRefs) {
|
|
249
294
|
const records = await listAgents(session);
|
|
250
295
|
const group = resolveFromList(records, groupRef);
|
|
251
|
-
|
|
296
|
+
if (!group.isGroup) throw new GatewayError(`"${group.name}" is a bot, not a group.`);
|
|
297
|
+
const memberAgentIds = normalizeMemberIds(records, memberRefs);
|
|
252
298
|
const data = await gatewayCall(session, "setGroupMembers", {
|
|
253
299
|
id: group.id,
|
|
254
300
|
memberAgentIds,
|
|
@@ -259,6 +305,7 @@ export async function setGroupMembers(session, groupRef, memberRefs) {
|
|
|
259
305
|
export async function addGroupMember(session, groupRef, memberRef) {
|
|
260
306
|
const records = await listAgents(session);
|
|
261
307
|
const group = resolveFromList(records, groupRef);
|
|
308
|
+
if (!group.isGroup) throw new GatewayError(`"${group.name}" is a bot, not a group.`);
|
|
262
309
|
const member = resolveFromList(records, memberRef);
|
|
263
310
|
const next = [...new Set([...group.memberIds, member.id])];
|
|
264
311
|
return setGroupMembers(session, group.id, next);
|
|
@@ -267,6 +314,7 @@ export async function addGroupMember(session, groupRef, memberRef) {
|
|
|
267
314
|
export async function removeGroupMember(session, groupRef, memberRef) {
|
|
268
315
|
const records = await listAgents(session);
|
|
269
316
|
const group = resolveFromList(records, groupRef);
|
|
317
|
+
if (!group.isGroup) throw new GatewayError(`"${group.name}" is a bot, not a group.`);
|
|
270
318
|
const member = resolveFromList(records, memberRef);
|
|
271
319
|
const next = group.memberIds.filter((id) => id !== member.id);
|
|
272
320
|
return setGroupMembers(session, group.id, next);
|
|
@@ -280,13 +328,29 @@ export async function sendPrompt(session, ref, prompt, extra = {}) {
|
|
|
280
328
|
clientNonce: extra.clientNonce || randomUUID(),
|
|
281
329
|
};
|
|
282
330
|
if (extra.replyToId) body.replyToId = extra.replyToId;
|
|
283
|
-
|
|
284
|
-
|
|
331
|
+
let data;
|
|
332
|
+
try {
|
|
333
|
+
data = await gatewayCall(session, "sendPrompt", body);
|
|
334
|
+
} catch (err) {
|
|
335
|
+
// Delivery states: the server answered no (rejected) vs the request may have landed (unknown).
|
|
336
|
+
// Never retry an unknown delivery blindly; read the thread first.
|
|
337
|
+
if (err && err.delivery == null) {
|
|
338
|
+
err.delivery = err instanceof GatewayError && err.status != null && err.status < 500 ? "rejected" : "unknown";
|
|
339
|
+
}
|
|
340
|
+
if (err && err.targetId == null) err.targetId = rec.id;
|
|
341
|
+
if (err && err.delivery === "unknown" && err instanceof Error && !/delivery unknown/.test(err.message)) {
|
|
342
|
+
err.message += " (delivery unknown; check the thread before resending)";
|
|
343
|
+
}
|
|
344
|
+
throw err;
|
|
345
|
+
}
|
|
346
|
+
const messageId = data && typeof data.messageId === "string" ? data.messageId : null;
|
|
347
|
+
return { target: rec, result: data, delivery: "accepted", ...(messageId ? { messageId } : {}) };
|
|
285
348
|
}
|
|
286
349
|
|
|
287
|
-
export async function getTranscriptTail(session, ref, limit =
|
|
350
|
+
export async function getTranscriptTail(session, ref, limit = 40) {
|
|
288
351
|
const rec = await resolveRef(session, ref);
|
|
289
|
-
const
|
|
352
|
+
const bounded = Math.min(Math.max(Math.trunc(limit) || 40, 1), 200);
|
|
353
|
+
const data = await gatewayCall(session, "getAgentTranscriptTail", { id: rec.id, limit: bounded });
|
|
290
354
|
return { target: rec, transcript: data };
|
|
291
355
|
}
|
|
292
356
|
|