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.
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
  };
@@ -63,8 +82,32 @@ export function hasGatewayAuth() {
63
82
  return Boolean(gatewayOverride() || accessTokenFromEnv() || hasGrokBotGatewaySession());
64
83
  }
65
84
 
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);
105
+ }
106
+ return Buffer.concat(chunks.map((c) => Buffer.from(c.buffer ?? c, c.byteOffset ?? 0, c.byteLength ?? c.length))).toString("utf8");
107
+ }
108
+
66
109
  async function readJson(res) {
67
- const text = await res.text();
110
+ const text = await readTextCapped(res, GATEWAY_MAX_RESPONSE_BYTES);
68
111
  if (!text) return {};
69
112
  try {
70
113
  return JSON.parse(text);
@@ -82,23 +125,25 @@ function pick(obj, ...keys) {
82
125
  }
83
126
 
84
127
  export async function ensureSandbox(accessToken) {
85
- const url = backendBase() + "/aiserver.v1.GrokBotService/EnsureSandBox";
128
+ const url = assertAllowedCredentialUrl(backendBase(), { kind: "backend" }) + "/aiserver.v1.GrokBotService/EnsureSandBox";
86
129
  const res = await fetch(url, {
87
130
  method: "POST",
131
+ redirect: "error",
132
+ signal: AbortSignal.timeout(GATEWAY_TIMEOUT_MS),
88
133
  headers: ensureSandboxHeaders(accessToken),
89
134
  body: "{}",
90
135
  });
91
136
  const body = await readJson(res);
92
137
  if (!res.ok) {
93
138
  const detail = body.message || body.error || body.raw || res.statusText;
94
- throw new GatewayError("EnsureSandBox failed: " + res.status + " " + detail, { status: res.status, method: "EnsureSandBox" });
139
+ throw new GatewayError("EnsureSandBox failed: " + res.status + " " + redactSecrets(detail), { status: res.status, method: "EnsureSandBox" });
95
140
  }
96
141
  const gatewayUrl = pick(body, "gatewayUrl", "gateway_url");
97
142
  const gatewayToken = pick(body, "gatewayToken", "gateway_token");
98
143
  if (!gatewayUrl || !gatewayToken) {
99
144
  throw new GatewayError("EnsureSandBox returned no gatewayUrl/gatewayToken. Auth may be a dashboard API key (those do not work).");
100
145
  }
101
- return { gatewayUrl: String(gatewayUrl).replace(/\/$/, ""), gatewayToken: String(gatewayToken), gatewayHeaders: mergeGatewayHeaders(headersFromEnsureSandbox(body), headersFromEnv()) };
146
+ return { gatewayUrl: assertAllowedCredentialUrl(String(gatewayUrl).replace(/\/$/, ""), { kind: "gateway" }), gatewayToken: String(gatewayToken), gatewayHeaders: mergeGatewayHeaders(headersFromEnsureSandbox(body), headersFromEnv()) };
102
147
  }
103
148
 
104
149
  export async function connectGateway() {
@@ -114,16 +159,19 @@ export async function connectGateway() {
114
159
  }
115
160
 
116
161
  export async function gatewayCall(session, method, body = {}) {
117
- const url = session.gatewayUrl + "/api/" + method;
162
+ const base = assertAllowedCredentialUrl(session.gatewayUrl, { kind: "gateway" });
163
+ const url = base + "/api/" + method;
118
164
  const res = await fetch(url, {
119
165
  method: "POST",
166
+ redirect: "error",
167
+ signal: AbortSignal.timeout(GATEWAY_TIMEOUT_MS),
120
168
  headers: requestHeaders(session),
121
169
  body: JSON.stringify(body),
122
170
  });
123
171
  const data = await readJson(res);
124
172
  if (!res.ok) {
125
173
  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 });
174
+ throw new GatewayError(method + " failed: " + res.status + " " + redactSecrets(String(detail).slice(0, 300)), { status: res.status, method });
127
175
  }
128
176
  return data;
129
177
  }
@@ -228,9 +276,27 @@ export async function deleteAgent(session, ref) {
228
276
  return rec;
229
277
  }
230
278
 
279
+ function normalizeMemberIds(records, memberRefs) {
280
+ const memberIds = new Set();
281
+ for (const ref of memberRefs) {
282
+ const rec = resolveFromList(records, ref);
283
+ if (rec.isGroup) {
284
+ throw new GatewayError(`Cannot add group "${rec.name}" as a member. Nested groups are not allowed.`);
285
+ }
286
+ memberIds.add(rec.id);
287
+ }
288
+ if (memberIds.size === 0) {
289
+ throw new GatewayError("A group needs at least one existing member agent.");
290
+ }
291
+ if (memberIds.size > MAX_GROUP_MEMBERS) {
292
+ throw new GatewayError(`A group can have at most ${MAX_GROUP_MEMBERS} members.`);
293
+ }
294
+ return [...memberIds];
295
+ }
296
+
231
297
  export async function createGroup(session, input) {
232
298
  const records = await listAgents(session);
233
- const memberAgentIds = (input.memberIds || []).map((ref) => resolveFromList(records, ref).id);
299
+ const memberAgentIds = normalizeMemberIds(records, input.memberIds || []);
234
300
  const data = await gatewayCall(session, "createGroup", {
235
301
  name: input.name,
236
302
  description: input.description || "",
@@ -248,7 +314,8 @@ export async function createGroup(session, input) {
248
314
  export async function setGroupMembers(session, groupRef, memberRefs) {
249
315
  const records = await listAgents(session);
250
316
  const group = resolveFromList(records, groupRef);
251
- const memberAgentIds = memberRefs.map((ref) => resolveFromList(records, ref).id);
317
+ if (!group.isGroup) throw new GatewayError(`"${group.name}" is a bot, not a group.`);
318
+ const memberAgentIds = normalizeMemberIds(records, memberRefs);
252
319
  const data = await gatewayCall(session, "setGroupMembers", {
253
320
  id: group.id,
254
321
  memberAgentIds,
@@ -259,6 +326,7 @@ export async function setGroupMembers(session, groupRef, memberRefs) {
259
326
  export async function addGroupMember(session, groupRef, memberRef) {
260
327
  const records = await listAgents(session);
261
328
  const group = resolveFromList(records, groupRef);
329
+ if (!group.isGroup) throw new GatewayError(`"${group.name}" is a bot, not a group.`);
262
330
  const member = resolveFromList(records, memberRef);
263
331
  const next = [...new Set([...group.memberIds, member.id])];
264
332
  return setGroupMembers(session, group.id, next);
@@ -267,6 +335,7 @@ export async function addGroupMember(session, groupRef, memberRef) {
267
335
  export async function removeGroupMember(session, groupRef, memberRef) {
268
336
  const records = await listAgents(session);
269
337
  const group = resolveFromList(records, groupRef);
338
+ if (!group.isGroup) throw new GatewayError(`"${group.name}" is a bot, not a group.`);
270
339
  const member = resolveFromList(records, memberRef);
271
340
  const next = group.memberIds.filter((id) => id !== member.id);
272
341
  return setGroupMembers(session, group.id, next);
@@ -280,13 +349,31 @@ export async function sendPrompt(session, ref, prompt, extra = {}) {
280
349
  clientNonce: extra.clientNonce || randomUUID(),
281
350
  };
282
351
  if (extra.replyToId) body.replyToId = extra.replyToId;
283
- const data = await gatewayCall(session, "sendPrompt", body);
284
- return { target: rec, result: data };
352
+ let data;
353
+ try {
354
+ data = await gatewayCall(session, "sendPrompt", body);
355
+ } catch (err) {
356
+ // Delivery states: the server answered no (rejected) vs the request may have landed (unknown).
357
+ // Never retry an unknown delivery blindly; read the thread first.
358
+ if (err && err.delivery == null) {
359
+ err.delivery = err instanceof GatewayError && err.status != null && err.status < 500 ? "rejected" : "unknown";
360
+ }
361
+ if (err && err.targetId == null) err.targetId = rec.id;
362
+ if (err && err.delivery === "unknown" && err instanceof Error && !/delivery unknown/.test(err.message)) {
363
+ err.message += " (delivery unknown; check the thread before resending)";
364
+ }
365
+ throw err;
366
+ }
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 } : {}) };
285
371
  }
286
372
 
287
- export async function getTranscriptTail(session, ref, limit = 50) {
373
+ export async function getTranscriptTail(session, ref, limit = 40) {
288
374
  const rec = await resolveRef(session, ref);
289
- const data = await gatewayCall(session, "getAgentTranscriptTail", { id: rec.id, limit });
375
+ const bounded = Math.min(Math.max(Math.trunc(limit) || 40, 1), 200);
376
+ const data = await gatewayCall(session, "getAgentTranscriptTail", { id: rec.id, limit: bounded });
290
377
  return { target: rec, transcript: data };
291
378
  }
292
379
 
package/src/history.js ADDED
@@ -0,0 +1,83 @@
1
+ import { appendFileSync, closeSync, constants, createReadStream, fstatSync, mkdirSync, openSync, readSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { createInterface } from "node:readline";
5
+ import { entryText, transcriptEntries } from "./transcript.js";
6
+
7
+ export function historyPath(dir) {
8
+ return join(resolve(dir || process.env.GROK_BOT_HISTORY_DIR || join(homedir(), ".grok-bot-cli")), "history.jsonl");
9
+ }
10
+
11
+ // Keep only conversation fields, never gateway responses, session credentials or bot instructions.
12
+ export function saveHistory(out, { dir, disabled, event, prompt, rootId } = {}) {
13
+ // Opt-in only: plaintext local history stays off unless GROK_BOT_HISTORY=on (or true/1).
14
+ if (disabled || !/^(on|true|1)$/i.test(process.env.GROK_BOT_HISTORY || "")) return;
15
+ try {
16
+ const recordedAt = new Date().toISOString();
17
+ const target = { id: out.target.id, name: out.target.name, kind: out.target.isGroup ? "group" : "bot" };
18
+ const payload = out.transcript || out.thread || out;
19
+ const entries = event === "send" ? [{ role: "user", text: prompt }] : transcriptEntries(payload);
20
+ const rows = entries.map((entry) => ({
21
+ version: 1,
22
+ recordedAt,
23
+ event,
24
+ target,
25
+ ...(rootId ? { rootId } : {}),
26
+ role: String(entry.role || entry.kind || entry.sender || entry.type || "msg"),
27
+ ...(entry.id || entry.messageId ? { messageId: String(entry.id || entry.messageId) } : {}),
28
+ ...(entry.timestamp || entry.createdAt ? { timestamp: String(entry.timestamp || entry.createdAt) } : {}),
29
+ text: entryText(entry),
30
+ }));
31
+ if (!rows.length) return;
32
+ const path = historyPath(dir);
33
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
34
+ const fd = openSync(path, constants.O_CREAT | constants.O_APPEND | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
35
+ try {
36
+ // Separate a previous interrupted append from the next complete record.
37
+ const size = fstatSync(fd).size;
38
+ const last = Buffer.alloc(1);
39
+ if (size) readSync(fd, last, 0, 1, size - 1);
40
+ const prefix = size && last[0] !== 10 ? "\n" : "";
41
+ appendFileSync(fd, prefix + rows.map((row) => JSON.stringify(row)).join("\n") + "\n");
42
+ } finally {
43
+ closeSync(fd);
44
+ }
45
+ } catch {
46
+ // A successful remote send must not look failed and invite an accidental resend.
47
+ process.stderr.write("Warning: could not save local history. Check the history directory and permissions.\n");
48
+ }
49
+ }
50
+
51
+ export async function readHistory(path, { ref, search, limit = 40 } = {}) {
52
+ const rows = [];
53
+ let malformed = 0;
54
+ const input = createReadStream(path, { encoding: "utf8" });
55
+ const lines = createInterface({ input, crlfDelay: Infinity });
56
+ try {
57
+ for await (const line of lines) {
58
+ if (!line.trim()) continue;
59
+ let row;
60
+ try {
61
+ row = JSON.parse(line);
62
+ if (row?.version !== 1 || typeof row.text !== "string" || typeof row.role !== "string" ||
63
+ typeof row.recordedAt !== "string" || typeof row.target?.id !== "string" || typeof row.target?.name !== "string") {
64
+ throw new Error("Invalid history record");
65
+ }
66
+ } catch {
67
+ malformed++;
68
+ continue;
69
+ }
70
+ if (ref && row.target.id !== ref && row.target.name.toLowerCase() !== ref.toLowerCase()) continue;
71
+ if (search !== undefined && !row.text.toLowerCase().includes(search.toLowerCase())) continue;
72
+ rows.push(row);
73
+ if (rows.length > limit) rows.shift();
74
+ }
75
+ } catch (err) {
76
+ if (err.code !== "ENOENT") throw err;
77
+ } finally {
78
+ lines.close();
79
+ input.destroy();
80
+ }
81
+ if (malformed) process.stderr.write("Warning: skipped " + malformed + " malformed local history record(s).\n");
82
+ return rows;
83
+ }
@@ -0,0 +1,50 @@
1
+ // Shared by `gbot thread` and the grok-bot plugin's gbot_thread tool.
2
+
3
+ /** Coerce anything to a string without throwing (numbers, BigInt, unserializable objects). */
4
+ export function toSafeText(value) {
5
+ if (typeof value === "string") return value;
6
+ if (value == null) return "";
7
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
8
+ try {
9
+ const out = JSON.stringify(value);
10
+ return typeof out === "string" ? out : "";
11
+ } catch {
12
+ return "[unserializable]";
13
+ }
14
+ }
15
+
16
+ /** Coerce to string and replace lone surrogates so downstream slicing/JSON never breaks. */
17
+ export function normalizeText(value) {
18
+ return toSafeText(value).replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD");
19
+ }
20
+
21
+ export function entryText(e) {
22
+ return normalizeText(entryTextRaw(e));
23
+ }
24
+
25
+ function entryTextRaw(e) {
26
+ if (!e || typeof e !== "object") return "";
27
+ // Prefer full body fields over `preview` (often truncated for list UIs).
28
+ const direct = e.text || e.prompt || e.message;
29
+ if (typeof direct === "string" && direct) return direct;
30
+ const content = e.content;
31
+ if (typeof content === "string") return content;
32
+ if (Array.isArray(content)) {
33
+ return content.map((part) => {
34
+ if (typeof part === "string") return part;
35
+ if (part && typeof part === "object") return part.text || part.content || "";
36
+ return "";
37
+ }).filter(Boolean).join("\n");
38
+ }
39
+ if (content && typeof content === "object") return content.text || toSafeText(content);
40
+ // Bot replies arrive as `{ kind: "send-message", message: { type, content } }`.
41
+ if (e.message && typeof e.message === "object" && typeof e.message.content === "string") return e.message.content;
42
+ return typeof e.preview === "string" ? e.preview : "";
43
+ }
44
+
45
+ export function transcriptEntries(payload) {
46
+ if (Array.isArray(payload)) return payload;
47
+ if (!payload || typeof payload !== "object") return [];
48
+ const entries = payload.entries || payload.messages || payload.items;
49
+ return Array.isArray(entries) ? entries : [];
50
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Gateway / backend URL policy: only send credentials to expected hosts.
3
+ *
4
+ * Gateway (box + API): https on *.cursor.sh / *.cursor.com (and apex) /
5
+ * *.cursorvm.com (EnsureSandBox box hosts, e.g. <id>-pod-<id>.us12.cursorvm.com).
6
+ * Backend (EnsureSandBox / Cursor API): https on *.cursor.sh / *.cursor.com only —
7
+ * never *.cursorvm.com, so a CURSOR_ACCESS_TOKEN cannot be pointed at a box host.
8
+ * Local/dev gateways: http(s)://127.0.0.1|localhost|::1 when GROK_BOT_ALLOW_LOCAL_GATEWAY=1.
9
+ * Escape hatch: GROK_BOT_ALLOW_ANY_GATEWAY=1 (unsafe; disables host checks; warns once).
10
+ */
11
+
12
+ function truthyEnv(name) {
13
+ const v = (process.env[name] || "").trim().toLowerCase();
14
+ return v === "1" || v === "true" || v === "yes";
15
+ }
16
+
17
+ export function allowAnyGateway() {
18
+ return truthyEnv("GROK_BOT_ALLOW_ANY_GATEWAY");
19
+ }
20
+
21
+ export function allowLocalGateway() {
22
+ return truthyEnv("GROK_BOT_ALLOW_LOCAL_GATEWAY");
23
+ }
24
+
25
+ const warned = new Set();
26
+
27
+ function warnOnce(key, message) {
28
+ if (warned.has(key)) return;
29
+ warned.add(key);
30
+ process.stderr.write(message + "\n");
31
+ }
32
+
33
+ /** Test hook: clear the one-shot warn set. */
34
+ export function resetPolicyWarnings() {
35
+ warned.clear();
36
+ }
37
+
38
+ function isLocalHostname(hostname) {
39
+ const h = String(hostname || "").toLowerCase().replace(/^\[|\]$/g, "");
40
+ return h === "localhost" || h === "127.0.0.1" || h === "::1";
41
+ }
42
+
43
+ function isCursorApiHostname(hostname) {
44
+ const h = String(hostname || "").toLowerCase();
45
+ if (!h) return false;
46
+ if (h === "cursor.sh" || h === "cursor.com") return true;
47
+ return h.endsWith(".cursor.sh") || h.endsWith(".cursor.com");
48
+ }
49
+
50
+ function isCursorGatewayHostname(hostname) {
51
+ const h = String(hostname || "").toLowerCase();
52
+ return isCursorApiHostname(h) || h.endsWith(".cursorvm.com");
53
+ }
54
+
55
+ /**
56
+ * @param {string} rawUrl
57
+ * @param {{ kind?: "gateway" | "backend" }} [opts]
58
+ * @returns {string} normalized URL without trailing slash
59
+ */
60
+ export function assertAllowedCredentialUrl(rawUrl, opts = {}) {
61
+ const kind = opts.kind || "gateway";
62
+ const label = kind === "backend" ? "backend URL" : "gateway URL";
63
+ let parsed;
64
+ try {
65
+ parsed = new URL(String(rawUrl));
66
+ } catch {
67
+ throw new Error("Invalid " + label + ".");
68
+ }
69
+
70
+ if (parsed.username || parsed.password) {
71
+ throw new Error("Rejected " + label + ": userinfo is not allowed.");
72
+ }
73
+
74
+ if (allowAnyGateway()) {
75
+ warnOnce(
76
+ "ALLOW_ANY",
77
+ "warning: GROK_BOT_ALLOW_ANY_GATEWAY is set; credential host checks are disabled.",
78
+ );
79
+ return String(rawUrl).replace(/\/$/, "");
80
+ }
81
+
82
+ const host = parsed.hostname;
83
+ const local = isLocalHostname(host);
84
+
85
+ if (local) {
86
+ if (kind === "backend") {
87
+ throw new Error(
88
+ "Rejected backend URL host \"" +
89
+ host +
90
+ "\". EnsureSandBox backends must be https on *.cursor.sh / *.cursor.com.",
91
+ );
92
+ }
93
+ if (!allowLocalGateway()) {
94
+ throw new Error(
95
+ "Rejected " +
96
+ label +
97
+ " host \"" +
98
+ host +
99
+ "\". Set GROK_BOT_ALLOW_LOCAL_GATEWAY=1 to permit localhost/127.0.0.1 gateways.",
100
+ );
101
+ }
102
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
103
+ throw new Error("Rejected " + label + ": local gateways must use http or https.");
104
+ }
105
+ warnOnce(
106
+ "ALLOW_LOCAL",
107
+ "warning: GROK_BOT_ALLOW_LOCAL_GATEWAY is set; credentials may be sent to a loopback gateway.",
108
+ );
109
+ return String(rawUrl).replace(/\/$/, "");
110
+ }
111
+
112
+ if (parsed.protocol !== "https:") {
113
+ throw new Error("Rejected " + label + ": only https is allowed (got " + parsed.protocol + ").");
114
+ }
115
+
116
+ const allowed =
117
+ kind === "backend" ? isCursorApiHostname(host) : isCursorGatewayHostname(host);
118
+ if (!allowed) {
119
+ const expected =
120
+ kind === "backend"
121
+ ? "*.cursor.sh / *.cursor.com"
122
+ : "*.cursor.sh / *.cursor.com / *.cursorvm.com";
123
+ throw new Error(
124
+ "Rejected " +
125
+ label +
126
+ " host \"" +
127
+ host +
128
+ "\". Expected " +
129
+ expected +
130
+ ", or set GROK_BOT_ALLOW_LOCAL_GATEWAY=1 / GROK_BOT_ALLOW_ANY_GATEWAY=1.",
131
+ );
132
+ }
133
+
134
+ return String(rawUrl).replace(/\/$/, "");
135
+ }
136
+
137
+ /**
138
+ * Redact common credential shapes from error / log strings.
139
+ * Covers Bearer/Basic/scheme Authorization values, cookie headers, and named token fields.
140
+ */
141
+ export function redactSecrets(text) {
142
+ let s = String(text);
143
+ // Cookie headers first so a later Authorization pass cannot swallow them.
144
+ // Stop the value before the next header-shaped token on the same line.
145
+ s = s.replace(
146
+ /(^|[\s,{;])((?:set-cookie|cookie)\s*[:=]\s*)([^\n;]+?)(?=\s+(?:set-cookie|cookie|authorization|proxy-authorization)\b|\s*$)/gi,
147
+ "$1$2<redacted>",
148
+ );
149
+ // Scheme + credential only (e.g. "Basic abc", "Bearer xyz") — not the rest of the line.
150
+ s = s.replace(
151
+ /(^|[\s,{;])((?:authorization|proxy-authorization)\s*[:=]\s*)(\S+(?:\s+\S+)?)/gi,
152
+ "$1$2<redacted>",
153
+ );
154
+ s = s.replace(/Bearer\s+[A-Za-z0-9._+\/=-]+/gi, "Bearer <redacted>");
155
+ s = s.replace(
156
+ /(["']?(?:authorization|gatewayToken|gateway_token|access_token|accessToken|refresh_token|refreshToken|token|x-anyrun-network-token|cookie|set-cookie)["']?\s*[:=]\s*["']?)([^"',\s}]+)/gi,
157
+ "$1<redacted>",
158
+ );
159
+ s = s.replace(/(x-anyrun-network-token\s*[=:]\s*)(\S+)/gi, "$1<redacted>");
160
+ return s;
161
+ }