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/README.md +100 -1
- package/package.json +3 -3
- package/src/app-session.js +98 -17
- package/src/cli.js +209 -31
- package/src/codex-bridge.js +496 -0
- package/src/gateway.js +103 -16
- package/src/history.js +83 -0
- package/src/transcript.js +50 -0
- package/src/url-policy.js +161 -0
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
|
};
|
|
@@ -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
|
|
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
|
|
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 || [])
|
|
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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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 =
|
|
373
|
+
export async function getTranscriptTail(session, ref, limit = 40) {
|
|
288
374
|
const rec = await resolveRef(session, ref);
|
|
289
|
-
const
|
|
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
|
+
}
|