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.
@@ -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) return { gatewayUrl: url.replace(/\/$/, ""), gatewayToken: token, gatewayHeaders: headersFromEnv() };
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
- const loaded = loadGrokBotGatewaySession();
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 url = session.gatewayUrl + "/api/" + method;
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 || []).map((ref) => resolveFromList(records, ref).id);
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
- const memberAgentIds = memberRefs.map((ref) => resolveFromList(records, ref).id);
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
- const data = await gatewayCall(session, "sendPrompt", body);
284
- return { target: rec, result: data };
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 = 50) {
350
+ export async function getTranscriptTail(session, ref, limit = 40) {
288
351
  const rec = await resolveRef(session, ref);
289
- const data = await gatewayCall(session, "getAgentTranscriptTail", { id: rec.id, limit });
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