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
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
|
+
}
|