grok-telegram-bot 2.1.0 → 2.2.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/.env.example +5 -0
- package/CHANGELOG.md +40 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/scripts/_push-branch.mjs +89 -0
- package/scripts/_release-version.mjs +87 -0
- package/src/app/accounts.ts +49 -12
- package/src/app/auth-service.ts +4 -1
- package/src/bot/account-rotator.ts +79 -52
- package/src/bot/bot.ts +4 -2
- package/src/bot/handlers/accounts.ts +22 -5
- package/src/bot/permission-service.ts +59 -15
- package/src/bot/reauth-controller.ts +3 -3
- package/src/config.ts +9 -0
- package/src/grok/client.ts +70 -19
package/.env.example
CHANGED
|
@@ -32,6 +32,11 @@ GROK_WORKSPACE=
|
|
|
32
32
|
# Grok runs risky tools (file writes, shell commands) — ACP "ask" mode.
|
|
33
33
|
GROK_TRUST_ALL_TOOLS=true
|
|
34
34
|
|
|
35
|
+
# Auto-approve ACP permission requests, preferring "allow for this session".
|
|
36
|
+
# Defaults true. Set false (and GROK_TRUST_ALL_TOOLS=false) for interactive
|
|
37
|
+
# Approve/Deny buttons in Telegram.
|
|
38
|
+
AUTO_APPROVE_PERMISSIONS=true
|
|
39
|
+
|
|
35
40
|
# Comma-separated roots the /projects browser is allowed to list.
|
|
36
41
|
# Supports ~ for home directory. Defaults to GROK_WORKSPACE's parent + home.
|
|
37
42
|
# Example: H:\Lucru\Domains,C:\Lucru\Domains
|
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,43 @@ The latest section is published verbatim as the GitHub Release notes by
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [2.2.0] - 2026-07-12
|
|
13
|
+
|
|
14
|
+
The **reliable multi-account** release — account switch / auto-rotate now only
|
|
15
|
+
swaps `~/.grok/auth.json` and restarts the agent headlessly (never opens a
|
|
16
|
+
browser), and tool permission prompts are auto-approved for the session by
|
|
17
|
+
default.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **🔁 Account switch & auto-rotate no longer open a browser.** Switching or
|
|
22
|
+
auto-rotating accounts now: (1) stops the shared agent, (2) replaces
|
|
23
|
+
`~/.grok/auth.json` with the saved snapshot, (3) starts the agent and
|
|
24
|
+
authenticates with headless `cached_token` only. Browser auth methods such as
|
|
25
|
+
`grok.com` are never selected (they used to hang the host and kill Telegram).
|
|
26
|
+
- **🪪 Explicit `activeId` tracking** so rotation still knows which account is
|
|
27
|
+
live after silent token refreshes (token-hash drift no longer confuses the
|
|
28
|
+
target list).
|
|
29
|
+
- **🛡️ Auth failures after a switch are surfaced** instead of silently running
|
|
30
|
+
an unauthenticated agent.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- **✅ Auto-approve permissions (session scope).** ACP
|
|
35
|
+
`session/request_permission` requests are auto-approved by default, preferring
|
|
36
|
+
“allow for this session” / “always allow” over “allow once”. Configure with
|
|
37
|
+
`AUTO_APPROVE_PERMISSIONS` (default `true`). Interactive Approve/Deny buttons
|
|
38
|
+
only when both `AUTO_APPROVE_PERMISSIONS=false` and `GROK_TRUST_ALL_TOOLS=false`.
|
|
39
|
+
- **`grok login --device-auth` for `/reauth`** — device-code sign-in streamed to
|
|
40
|
+
Telegram instead of opening a browser on the host.
|
|
41
|
+
- **`--no-leader`** on `grok agent` so auth is process-local and auth.json swaps
|
|
42
|
+
take effect on restart.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- Headless auth method selection prefers `cached_token` (multi-account) over
|
|
47
|
+
API key / browser methods.
|
|
48
|
+
|
|
12
49
|
## [2.1.0] - 2026-07-10
|
|
13
50
|
|
|
14
51
|
The **"show me everything"** release — the bot now streams rich, real-time detail
|
|
@@ -632,6 +669,9 @@ from a single chat and switch between them, on a redesigned, compact menu.
|
|
|
632
669
|
diffs, MarkdownV2 rendering, scheduled tasks, multi-image prompts, and a
|
|
633
670
|
cross-platform 24/7 background service.
|
|
634
671
|
|
|
672
|
+
[2.2.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.2.0
|
|
673
|
+
[2.1.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.1.0
|
|
674
|
+
[2.0.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.0.0
|
|
635
675
|
[1.7.1]: https://github.com/artickc/grok-telegram-bot/releases/tag/v1.7.1
|
|
636
676
|
[1.7.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v1.7.0
|
|
637
677
|
[1.6.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v1.6.0
|
package/README.md
CHANGED
|
@@ -17,8 +17,8 @@ account (SuperGrok / X Premium+), send a message from anywhere, and watch Grok
|
|
|
17
17
|
plan, read files, run commands, and edit code on your machine — with live typing
|
|
18
18
|
indicators, clean Telegram markdown, and unified edit diffs.
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
and extended into a full multi-session client.
|
|
20
|
+
A fork of [`artickc/kiro-telegram-bot`](https://github.com/artickc/kiro-telegram-bot),
|
|
21
|
+
re-architected for the Grok Build CLI and extended into a full multi-session client.
|
|
22
22
|
|
|
23
23
|
---
|
|
24
24
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "grok-telegram-bot",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Control the official Grok Build CLI from Telegram over the Agent Client Protocol (ACP). Sign in with your xAI account, switch projects, resume sessions, stream responses with diffs, queue follow-ups, manage multiple sign-ins, and run 24/7 as a cross-platform background service.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
|
|
4
|
+
const files = [
|
|
5
|
+
".env.example",
|
|
6
|
+
"CHANGELOG.md",
|
|
7
|
+
"src/app/accounts.ts",
|
|
8
|
+
"src/app/auth-service.ts",
|
|
9
|
+
"src/bot/account-rotator.ts",
|
|
10
|
+
"src/bot/bot.ts",
|
|
11
|
+
"src/bot/handlers/accounts.ts",
|
|
12
|
+
"src/bot/permission-service.ts",
|
|
13
|
+
"src/bot/reauth-controller.ts",
|
|
14
|
+
"src/config.ts",
|
|
15
|
+
"src/grok/client.ts",
|
|
16
|
+
"test/auth-and-permissions.test.ts",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const owner = "artickc";
|
|
20
|
+
const repo = "grok-telegram-bot";
|
|
21
|
+
const branch = "feat/fix-rotation-and-auto-approve";
|
|
22
|
+
const message =
|
|
23
|
+
"fix: account rotation swaps auth.json headlessly; auto-approve session permissions";
|
|
24
|
+
|
|
25
|
+
// eslint-disable-next-line no-control-regex
|
|
26
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
27
|
+
|
|
28
|
+
function ghApi(method, path, body) {
|
|
29
|
+
const args = ["api", "-X", method, path];
|
|
30
|
+
const opts = {
|
|
31
|
+
encoding: "utf8",
|
|
32
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
33
|
+
shell: false,
|
|
34
|
+
env: { ...process.env, NO_COLOR: "1", CLICOLOR: "0", FORCE_COLOR: "0", GH_FORCE_TTY: "0" },
|
|
35
|
+
};
|
|
36
|
+
if (body !== undefined) {
|
|
37
|
+
args.push("--input", "-");
|
|
38
|
+
opts.input = JSON.stringify(body);
|
|
39
|
+
}
|
|
40
|
+
const r = spawnSync("gh", args, opts);
|
|
41
|
+
if (r.status !== 0) {
|
|
42
|
+
throw new Error(`gh api ${method} ${path} failed (${r.status}): ${r.stderr || r.stdout}`);
|
|
43
|
+
}
|
|
44
|
+
const text = (r.stdout || "").replace(ANSI_RE, "").trim();
|
|
45
|
+
if (!text) return {};
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(text);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
throw new Error(`JSON parse failed for ${method} ${path}: ${text.slice(0, 200)}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const refPath = `repos/${owner}/${repo}/git/ref/heads/${branch}`;
|
|
54
|
+
const ref = ghApi("GET", refPath);
|
|
55
|
+
const baseSha = ref.object.sha;
|
|
56
|
+
console.log("base", baseSha);
|
|
57
|
+
|
|
58
|
+
const baseCommit = ghApi("GET", `repos/${owner}/${repo}/git/commits/${baseSha}`);
|
|
59
|
+
const baseTree = baseCommit.tree.sha;
|
|
60
|
+
|
|
61
|
+
const tree = [];
|
|
62
|
+
for (const path of files) {
|
|
63
|
+
const content = readFileSync(path, "utf8");
|
|
64
|
+
const blob = ghApi("POST", `repos/${owner}/${repo}/git/blobs`, {
|
|
65
|
+
content: Buffer.from(content, "utf8").toString("base64"),
|
|
66
|
+
encoding: "base64",
|
|
67
|
+
});
|
|
68
|
+
tree.push({ path, mode: "100644", type: "blob", sha: blob.sha });
|
|
69
|
+
console.log("blob", path, blob.sha.slice(0, 8));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const newTree = ghApi("POST", `repos/${owner}/${repo}/git/trees`, {
|
|
73
|
+
base_tree: baseTree,
|
|
74
|
+
tree,
|
|
75
|
+
});
|
|
76
|
+
console.log("tree", newTree.sha);
|
|
77
|
+
|
|
78
|
+
const commit = ghApi("POST", `repos/${owner}/${repo}/git/commits`, {
|
|
79
|
+
message,
|
|
80
|
+
tree: newTree.sha,
|
|
81
|
+
parents: [baseSha],
|
|
82
|
+
});
|
|
83
|
+
console.log("commit", commit.sha);
|
|
84
|
+
|
|
85
|
+
ghApi("PATCH", `repos/${owner}/${repo}/git/refs/heads/${branch}`, {
|
|
86
|
+
sha: commit.sha,
|
|
87
|
+
force: false,
|
|
88
|
+
});
|
|
89
|
+
console.log("updated", branch, "->", commit.sha);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bump package.json on main + create vX.Y.Z tag via the GitHub API
|
|
3
|
+
* (local git push/commit is restricted in this environment).
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
const owner = "artickc";
|
|
9
|
+
const repo = "grok-telegram-bot";
|
|
10
|
+
const branch = "main";
|
|
11
|
+
const version = "2.2.0";
|
|
12
|
+
const tag = `v${version}`;
|
|
13
|
+
const message = `v${version} — headless account rotation + auto-approve session permissions`;
|
|
14
|
+
|
|
15
|
+
// eslint-disable-next-line no-control-regex
|
|
16
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
17
|
+
|
|
18
|
+
function ghApi(method, path, body) {
|
|
19
|
+
const args = ["api", "-X", method, path];
|
|
20
|
+
const opts = {
|
|
21
|
+
encoding: "utf8",
|
|
22
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
23
|
+
shell: false,
|
|
24
|
+
env: { ...process.env, NO_COLOR: "1", CLICOLOR: "0", FORCE_COLOR: "0", GH_FORCE_TTY: "0" },
|
|
25
|
+
};
|
|
26
|
+
if (body !== undefined) {
|
|
27
|
+
args.push("--input", "-");
|
|
28
|
+
opts.input = JSON.stringify(body);
|
|
29
|
+
}
|
|
30
|
+
const r = spawnSync("gh", args, opts);
|
|
31
|
+
if (r.status !== 0) {
|
|
32
|
+
throw new Error(`gh api ${method} ${path} failed (${r.status}): ${r.stderr || r.stdout}`);
|
|
33
|
+
}
|
|
34
|
+
const text = (r.stdout || "").replace(ANSI_RE, "").trim();
|
|
35
|
+
if (!text) return {};
|
|
36
|
+
return JSON.parse(text);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ref = ghApi("GET", `repos/${owner}/${repo}/git/ref/heads/${branch}`);
|
|
40
|
+
const baseSha = ref.object.sha;
|
|
41
|
+
const baseCommit = ghApi("GET", `repos/${owner}/${repo}/git/commits/${baseSha}`);
|
|
42
|
+
const baseTree = baseCommit.tree.sha;
|
|
43
|
+
console.log("base", baseSha);
|
|
44
|
+
|
|
45
|
+
const content = readFileSync("package.json", "utf8");
|
|
46
|
+
const blob = ghApi("POST", `repos/${owner}/${repo}/git/blobs`, {
|
|
47
|
+
content: Buffer.from(content, "utf8").toString("base64"),
|
|
48
|
+
encoding: "base64",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const newTree = ghApi("POST", `repos/${owner}/${repo}/git/trees`, {
|
|
52
|
+
base_tree: baseTree,
|
|
53
|
+
tree: [{ path: "package.json", mode: "100644", type: "blob", sha: blob.sha }],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const commit = ghApi("POST", `repos/${owner}/${repo}/git/commits`, {
|
|
57
|
+
message,
|
|
58
|
+
tree: newTree.sha,
|
|
59
|
+
parents: [baseSha],
|
|
60
|
+
});
|
|
61
|
+
console.log("commit", commit.sha);
|
|
62
|
+
|
|
63
|
+
ghApi("PATCH", `repos/${owner}/${repo}/git/refs/heads/${branch}`, {
|
|
64
|
+
sha: commit.sha,
|
|
65
|
+
force: false,
|
|
66
|
+
});
|
|
67
|
+
console.log("updated", branch, "->", commit.sha);
|
|
68
|
+
|
|
69
|
+
// Annotated tag for release workflow.
|
|
70
|
+
const tagObj = ghApi("POST", `repos/${owner}/${repo}/git/tags`, {
|
|
71
|
+
tag,
|
|
72
|
+
message: message,
|
|
73
|
+
object: commit.sha,
|
|
74
|
+
type: "commit",
|
|
75
|
+
tagger: {
|
|
76
|
+
name: "artickc",
|
|
77
|
+
email: "artickc@users.noreply.github.com",
|
|
78
|
+
date: new Date().toISOString(),
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
console.log("tag object", tagObj.sha);
|
|
82
|
+
|
|
83
|
+
ghApi("POST", `repos/${owner}/${repo}/git/refs`, {
|
|
84
|
+
ref: `refs/tags/${tag}`,
|
|
85
|
+
sha: tagObj.sha,
|
|
86
|
+
});
|
|
87
|
+
console.log("created tag", tag);
|
package/src/app/accounts.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Snapshots are copies of auth.json under `<dataDir>/accounts/` (git-ignored).
|
|
12
12
|
* The index stores only a label + token hash, never the token itself.
|
|
13
13
|
*/
|
|
14
|
-
import {
|
|
14
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
import { createLogger } from "../logger.js";
|
|
17
17
|
import { JsonStore } from "./json-store.js";
|
|
@@ -37,6 +37,8 @@ export interface StoredAccount {
|
|
|
37
37
|
interface AccountsData {
|
|
38
38
|
accounts: StoredAccount[];
|
|
39
39
|
autoRotate?: boolean;
|
|
40
|
+
/** Explicitly tracked active account id (survives token-hash drift after refresh). */
|
|
41
|
+
activeId?: string;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
function makeId(): string {
|
|
@@ -73,11 +75,18 @@ export class AccountManager {
|
|
|
73
75
|
return this.store.get().accounts.find((a) => a.email === key || a.startUrl === key || a.loginId === key);
|
|
74
76
|
}
|
|
75
77
|
|
|
76
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Id of the currently active saved account. Prefers the last switch target
|
|
80
|
+
* (`activeId`), then falls back to matching the live auth.json token hash.
|
|
81
|
+
*/
|
|
77
82
|
activeAccountId(): string | undefined {
|
|
83
|
+
const data = this.store.get();
|
|
84
|
+
if (data.activeId && data.accounts.some((a) => a.id === data.activeId)) {
|
|
85
|
+
return data.activeId;
|
|
86
|
+
}
|
|
78
87
|
const lid = loginId();
|
|
79
88
|
if (!lid) return undefined;
|
|
80
|
-
return
|
|
89
|
+
return data.accounts.find((a) => a.loginId === lid)?.id;
|
|
81
90
|
}
|
|
82
91
|
|
|
83
92
|
get(id: string): StoredAccount | undefined {
|
|
@@ -98,23 +107,36 @@ export class AccountManager {
|
|
|
98
107
|
const lid = loginId();
|
|
99
108
|
if (!lid) throw new Error("No browser sign-in to save (an XAI_API_KEY-only login can't be snapshotted).");
|
|
100
109
|
await mkdir(this.dir, { recursive: true });
|
|
110
|
+
// Prefer a usable token in the live file; reject empty/corrupt auth.json.
|
|
111
|
+
const raw = await readFile(grokAuthPath(), "utf-8").catch(() => undefined);
|
|
112
|
+
if (!raw?.trim()) throw new Error("auth.json is empty or missing — run /reauth first.");
|
|
113
|
+
try {
|
|
114
|
+
JSON.parse(raw);
|
|
115
|
+
} catch {
|
|
116
|
+
throw new Error("auth.json is not valid JSON — run /reauth to repair it.");
|
|
117
|
+
}
|
|
101
118
|
const label = customLabel?.trim() || loginLabel() || `account ${lid.slice(0, 6)}`;
|
|
102
119
|
const email = loginLabel();
|
|
103
|
-
|
|
120
|
+
// Match by token hash first; fall back to the currently marked active slot
|
|
121
|
+
// when the user is re-saving after a silent token refresh.
|
|
122
|
+
const existing =
|
|
123
|
+
this.store.get().accounts.find((a) => a.loginId === lid) ??
|
|
124
|
+
(this.store.get().activeId ? this.get(this.store.get().activeId!) : undefined);
|
|
104
125
|
const id = existing?.id ?? makeId();
|
|
105
|
-
await
|
|
126
|
+
await writeFile(this.snapshotPath(id), raw, "utf-8");
|
|
106
127
|
const meta: StoredAccount = {
|
|
107
128
|
id,
|
|
108
|
-
label,
|
|
129
|
+
label: customLabel?.trim() || existing?.label || label,
|
|
109
130
|
loginId: lid,
|
|
110
|
-
email,
|
|
111
|
-
startUrl: email,
|
|
131
|
+
email: email || existing?.email,
|
|
132
|
+
startUrl: email || existing?.email,
|
|
112
133
|
savedAt: new Date().toISOString(),
|
|
113
134
|
};
|
|
114
135
|
this.store.update((d) => {
|
|
115
136
|
const idx = d.accounts.findIndex((a) => a.id === id);
|
|
116
137
|
if (idx >= 0) d.accounts[idx] = meta;
|
|
117
138
|
else d.accounts.push(meta);
|
|
139
|
+
d.activeId = id;
|
|
118
140
|
});
|
|
119
141
|
log.info(`captured account ${meta.label} (${id})`);
|
|
120
142
|
return meta;
|
|
@@ -122,18 +144,32 @@ export class AccountManager {
|
|
|
122
144
|
|
|
123
145
|
/**
|
|
124
146
|
* Make a saved account the active sign-in by copying its snapshot over
|
|
125
|
-
* auth.json. The caller
|
|
126
|
-
*
|
|
147
|
+
* auth.json. The caller MUST stop the ACP agent first (so it cannot rewrite
|
|
148
|
+
* auth.json mid-swap), then restart after this returns. Never opens a
|
|
149
|
+
* browser — pure file replace. Throws when the snapshot is missing/invalid.
|
|
127
150
|
*/
|
|
128
151
|
async switchTo(id: string): Promise<StoredAccount> {
|
|
129
152
|
const meta = this.get(id);
|
|
130
153
|
if (!meta) throw new Error("That account is no longer saved.");
|
|
131
154
|
const snap = this.snapshotPath(id);
|
|
132
155
|
const raw = await readFile(snap, "utf-8").catch(() => undefined);
|
|
133
|
-
if (!raw) throw new Error(`Saved login for ${meta.label} is missing — re-add it.`);
|
|
156
|
+
if (!raw?.trim()) throw new Error(`Saved login for ${meta.label} is missing — re-add it.`);
|
|
157
|
+
let parsed: unknown;
|
|
158
|
+
try {
|
|
159
|
+
parsed = JSON.parse(raw);
|
|
160
|
+
} catch {
|
|
161
|
+
throw new Error(`Saved login for ${meta.label} is corrupt — re-save it via /accounts.`);
|
|
162
|
+
}
|
|
163
|
+
// Sanity-check: snapshot must look like auth.json (object with at least one key).
|
|
164
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || Object.keys(parsed as object).length === 0) {
|
|
165
|
+
throw new Error(`Saved login for ${meta.label} has no token — re-save it via /accounts.`);
|
|
166
|
+
}
|
|
134
167
|
await mkdir(join(grokAuthPath(), ".."), { recursive: true });
|
|
135
168
|
await writeFile(grokAuthPath(), raw, "utf-8");
|
|
136
|
-
|
|
169
|
+
this.store.update((d) => {
|
|
170
|
+
d.activeId = id;
|
|
171
|
+
});
|
|
172
|
+
log.info(`switched active login to ${meta.label} (${id}) — auth.json replaced`);
|
|
137
173
|
return meta;
|
|
138
174
|
}
|
|
139
175
|
|
|
@@ -156,6 +192,7 @@ export class AccountManager {
|
|
|
156
192
|
await rm(this.snapshotPath(id), { force: true }).catch(() => {});
|
|
157
193
|
this.store.update((d) => {
|
|
158
194
|
d.accounts = d.accounts.filter((a) => a.id !== id);
|
|
195
|
+
if (d.activeId === id) d.activeId = undefined;
|
|
159
196
|
});
|
|
160
197
|
return existed;
|
|
161
198
|
}
|
package/src/app/auth-service.ts
CHANGED
|
@@ -68,7 +68,10 @@ export class AuthService {
|
|
|
68
68
|
if (signal?.aborted) return resolve({ ok: false, code: null, cancelled: true });
|
|
69
69
|
let proc;
|
|
70
70
|
try {
|
|
71
|
-
|
|
71
|
+
// Prefer device-code login so a headless/service host never opens a
|
|
72
|
+
// browser window (which hangs the bot). The verification URL + code are
|
|
73
|
+
// streamed to Telegram via onOutput.
|
|
74
|
+
proc = spawn(this.grokCliPath, ["login", "--device-auth"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
72
75
|
} catch (e) {
|
|
73
76
|
onOutput(`error: ${(e as Error).message}`);
|
|
74
77
|
return resolve({ ok: false, code: null });
|
|
@@ -1,52 +1,79 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Auto-rotate-on-give-up. When a turn exhausts its retries (and auto-fork can't
|
|
3
|
-
* recover it), the runtime can cycle through the OTHER saved Grok accounts,
|
|
4
|
-
* retrying the same prompt on each — useful when the active account is
|
|
5
|
-
* throttled, out of quota, or its backend keeps returning "dispatch failure".
|
|
6
|
-
*
|
|
7
|
-
* The rotation is bounded to a SINGLE pass over the saved accounts (no infinite
|
|
8
|
-
* loop): each account is tried once; the first that succeeds wins and stays
|
|
9
|
-
* active, otherwise the runtime reports the error gathered from every account.
|
|
10
|
-
*
|
|
11
|
-
* Account switching is process-global (one machine → one active Grok login), so
|
|
12
|
-
* a rotation restarts the shared agent and affects every chat — intended, since
|
|
13
|
-
* the whole point is to move everyone onto a working login.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
async
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Auto-rotate-on-give-up. When a turn exhausts its retries (and auto-fork can't
|
|
3
|
+
* recover it), the runtime can cycle through the OTHER saved Grok accounts,
|
|
4
|
+
* retrying the same prompt on each — useful when the active account is
|
|
5
|
+
* throttled, out of quota, or its backend keeps returning "dispatch failure".
|
|
6
|
+
*
|
|
7
|
+
* The rotation is bounded to a SINGLE pass over the saved accounts (no infinite
|
|
8
|
+
* loop): each account is tried once; the first that succeeds wins and stays
|
|
9
|
+
* active, otherwise the runtime reports the error gathered from every account.
|
|
10
|
+
*
|
|
11
|
+
* Account switching is process-global (one machine → one active Grok login), so
|
|
12
|
+
* a rotation restarts the shared agent and affects every chat — intended, since
|
|
13
|
+
* the whole point is to move everyone onto a working login.
|
|
14
|
+
*
|
|
15
|
+
* CRITICAL: switch = stop agent → replace ~/.grok/auth.json → start agent with
|
|
16
|
+
* headless `cached_token` auth. Never opens a browser / never runs `grok login`.
|
|
17
|
+
*/
|
|
18
|
+
import type { GrokClient } from "../grok/client.js";
|
|
19
|
+
import type { AccountManager } from "../app/accounts.js";
|
|
20
|
+
import { createLogger } from "../logger.js";
|
|
21
|
+
|
|
22
|
+
const log = createLogger("account-rotator");
|
|
23
|
+
|
|
24
|
+
export interface RotationTarget {
|
|
25
|
+
id: string;
|
|
26
|
+
label: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AccountRotator {
|
|
30
|
+
/** Whether auto-rotate is switched on. */
|
|
31
|
+
enabled(): boolean;
|
|
32
|
+
/** Saved accounts to try, EXCLUDING the one that's currently active. */
|
|
33
|
+
targets(): Promise<RotationTarget[]>;
|
|
34
|
+
/** Make a saved account active (swap auth.json + re-bind). Throws on error. */
|
|
35
|
+
activate(id: string): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class AccountRotatorImpl implements AccountRotator {
|
|
39
|
+
constructor(
|
|
40
|
+
private readonly accounts: AccountManager,
|
|
41
|
+
private readonly acp: GrokClient,
|
|
42
|
+
) {}
|
|
43
|
+
|
|
44
|
+
enabled(): boolean {
|
|
45
|
+
return this.accounts.autoRotateEnabled();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async targets(): Promise<RotationTarget[]> {
|
|
49
|
+
const list = this.accounts.list();
|
|
50
|
+
const activeId = this.accounts.activeAccountId();
|
|
51
|
+
return list.filter((a) => a.id !== activeId).map((a) => ({ id: a.id, label: a.label }));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Pure file-based account swap:
|
|
56
|
+
* 1. Stop the shared agent (so it cannot rewrite auth.json),
|
|
57
|
+
* 2. Copy the saved snapshot over ~/.grok/auth.json,
|
|
58
|
+
* 3. Start the agent and authenticate with `cached_token` (headless).
|
|
59
|
+
* Never launches a browser.
|
|
60
|
+
*/
|
|
61
|
+
async activate(id: string): Promise<void> {
|
|
62
|
+
// Snapshot the current login first so we never lose it mid-rotation.
|
|
63
|
+
await this.accounts.captureCurrent().catch((e) => {
|
|
64
|
+
log.warn("pre-rotate capture failed (continuing):", (e as Error).message);
|
|
65
|
+
});
|
|
66
|
+
log.info(`rotating: stopping agent before auth.json swap (${id})`);
|
|
67
|
+
await this.acp.stopAndWait();
|
|
68
|
+
try {
|
|
69
|
+
const meta = await this.accounts.switchTo(id);
|
|
70
|
+
log.info(`rotating: auth.json now ${meta.label}; starting agent`);
|
|
71
|
+
await this.acp.start();
|
|
72
|
+
} catch (e) {
|
|
73
|
+
// Best-effort recover the agent so the bot stays usable even if the
|
|
74
|
+
// target login was bad.
|
|
75
|
+
await this.acp.start().catch((err) => log.warn("post-rotate restart failed:", (err as Error).message));
|
|
76
|
+
throw e;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/bot/bot.ts
CHANGED
|
@@ -123,8 +123,10 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
123
123
|
// Auto-rotate-on-give-up: let a stuck turn cycle through other saved logins.
|
|
124
124
|
registry.setAccountRotator(new AccountRotatorImpl(deps.accounts, acp));
|
|
125
125
|
|
|
126
|
-
//
|
|
127
|
-
|
|
126
|
+
// Permission handling: default is auto-approve (prefer "this session" / always).
|
|
127
|
+
// Interactive Approve/Deny buttons only when both trust-all and auto-approve are off.
|
|
128
|
+
const autoApprovePerms = cfg.autoApprovePermissions || cfg.trustAllTools;
|
|
129
|
+
const permissions = new PermissionService(bot.api, registry, autoApprovePerms);
|
|
128
130
|
acp.permissionHandler = (p) => permissions.handle(p);
|
|
129
131
|
|
|
130
132
|
// The bot pins/unpins the status panel, and Telegram emits a "pinned a
|
|
@@ -175,9 +175,12 @@ export function registerAccounts(bot: Bot, deps: BotDeps): void {
|
|
|
175
175
|
await ctx.answerCallbackQuery({ text: "Importing…" });
|
|
176
176
|
const res = await auth.importExisting();
|
|
177
177
|
if (!res.ok) return void rerender(ctx, deps, `\u274C ${res.error ?? "Import failed."}`);
|
|
178
|
+
// Import reuses the live auth.json — just re-bind the agent headlessly.
|
|
178
179
|
try {
|
|
179
|
-
await deps.acp.
|
|
180
|
+
await deps.acp.stopAndWait();
|
|
181
|
+
await deps.acp.start();
|
|
180
182
|
} catch (e) {
|
|
183
|
+
await deps.acp.start().catch(() => {});
|
|
181
184
|
return void rerender(ctx, deps, `\u26A0\uFE0F Imported, but re-bind failed: ${(e as Error).message}`);
|
|
182
185
|
}
|
|
183
186
|
let note = `\u2705 Imported the current login${res.label ? ` (${res.label})` : ""}.`;
|
|
@@ -196,10 +199,24 @@ export function registerAccounts(bot: Bot, deps: BotDeps): void {
|
|
|
196
199
|
if (reason) return void ctx.answerCallbackQuery({ text: reason, show_alert: true });
|
|
197
200
|
await ctx.answerCallbackQuery({ text: "Switching…" });
|
|
198
201
|
try {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
await
|
|
202
|
+
// 1) Snapshot the current login so it isn't lost.
|
|
203
|
+
await deps.accounts.captureCurrent().catch(() => {});
|
|
204
|
+
const target = deps.accounts.get(id);
|
|
205
|
+
await ctx
|
|
206
|
+
.editMessageText(`\u{1F504} Switching to ${target?.label ?? "account"}\u2026 replacing auth.json + restarting agent`)
|
|
207
|
+
.catch(() => {});
|
|
208
|
+
// 2) Stop agent BEFORE writing auth.json (avoids the live process
|
|
209
|
+
// overwriting / racing the file, and never opens a browser).
|
|
210
|
+
await deps.acp.stopAndWait();
|
|
211
|
+
let meta;
|
|
212
|
+
try {
|
|
213
|
+
meta = await deps.accounts.switchTo(id);
|
|
214
|
+
// 3) Start agent; it authenticates headlessly with cached_token.
|
|
215
|
+
await deps.acp.start();
|
|
216
|
+
} catch (e) {
|
|
217
|
+
await deps.acp.start().catch(() => {});
|
|
218
|
+
throw e;
|
|
219
|
+
}
|
|
203
220
|
const note = (await deps.usage.isLoggedIn())
|
|
204
221
|
? `\u2705 Now signed in as ${meta.label}. Your next message runs on this account.`
|
|
205
222
|
: `\u26A0\uFE0F Switched to ${meta.label}, but no usable login is active. ${UNSUPPORTED_LOGIN_HELP}`;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* PermissionService — turns Grok's ACP `session/request_permission` into
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* PermissionService — turns Grok's ACP `session/request_permission` into either
|
|
3
|
+
* an automatic session-level approval (default) or inline Approve/Deny buttons.
|
|
4
|
+
*
|
|
5
|
+
* Auto-approve prefers "allow for this session" / "always allow" options so the
|
|
6
|
+
* agent stops re-prompting mid-turn. Interactive mode is only used when
|
|
7
|
+
* auto-approve is off (GROK_TRUST_ALL_TOOLS=false and AUTO_APPROVE_PERMISSIONS=false).
|
|
7
8
|
*/
|
|
8
9
|
import type { Api } from "grammy";
|
|
9
10
|
import { InlineKeyboard } from "grammy";
|
|
@@ -35,17 +36,31 @@ interface Pending {
|
|
|
35
36
|
export class PermissionService {
|
|
36
37
|
private readonly pending = new Map<string, Pending>();
|
|
37
38
|
private seq = 0;
|
|
39
|
+
/** When true, every permission request is auto-approved (session-scope preferred). */
|
|
40
|
+
autoApprove: boolean;
|
|
38
41
|
|
|
39
42
|
constructor(
|
|
40
43
|
private readonly api: Api,
|
|
41
44
|
private readonly registry: RuntimeRegistry,
|
|
42
|
-
|
|
45
|
+
autoApprove = true,
|
|
46
|
+
) {
|
|
47
|
+
this.autoApprove = autoApprove;
|
|
48
|
+
}
|
|
43
49
|
|
|
44
|
-
/** Handle a permission request: ask the
|
|
50
|
+
/** Handle a permission request: auto-approve (default), ask the chat, or allow if unattended. */
|
|
45
51
|
async handle(params: RequestPermissionParams): Promise<PermissionOutcome> {
|
|
52
|
+
if (this.autoApprove) {
|
|
53
|
+
const decision = autoDecideSession(params);
|
|
54
|
+
log.info(
|
|
55
|
+
`auto-approved permission for session ${params.sessionId.slice(0, 8)} ` +
|
|
56
|
+
`(${params.toolCall?.kind ?? "tool"}: ${params.toolCall?.title ?? "?"})`,
|
|
57
|
+
);
|
|
58
|
+
return decision;
|
|
59
|
+
}
|
|
60
|
+
|
|
46
61
|
const desc = this.registry.describeSession(params.sessionId);
|
|
47
62
|
const chatId = desc.chatId;
|
|
48
|
-
if (chatId === undefined) return
|
|
63
|
+
if (chatId === undefined) return autoDecideSession(params); // unattended (scheduled / orphan)
|
|
49
64
|
|
|
50
65
|
const reqId = String(++this.seq);
|
|
51
66
|
const isForeground = !desc.subagent && this.registry.get(chatId).sessionId === params.sessionId;
|
|
@@ -75,7 +90,7 @@ export class PermissionService {
|
|
|
75
90
|
messageId = msg.message_id;
|
|
76
91
|
} catch (e) {
|
|
77
92
|
log.warn("failed to send permission prompt:", (e as Error).message);
|
|
78
|
-
return
|
|
93
|
+
return autoDecideSession(params);
|
|
79
94
|
}
|
|
80
95
|
|
|
81
96
|
return new Promise<PermissionOutcome>((resolve) => {
|
|
@@ -136,14 +151,43 @@ function describe(
|
|
|
136
151
|
|
|
137
152
|
function buttonLabel(o: { name: string; kind?: string }): string {
|
|
138
153
|
const k = `${o.kind ?? ""} ${o.name}`.toLowerCase();
|
|
139
|
-
const icon = /reject|deny|no|cancel/.test(k) ? "\u26D4" : /always|all/.test(k) ? "\u2705\u267E\uFE0F" : "\u2705";
|
|
154
|
+
const icon = /reject|deny|no|cancel/.test(k) ? "\u26D4" : /always|all|session/.test(k) ? "\u2705\u267E\uFE0F" : "\u2705";
|
|
140
155
|
return `${icon} ${o.name}`;
|
|
141
156
|
}
|
|
142
157
|
|
|
143
|
-
/**
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
158
|
+
/**
|
|
159
|
+
* Score an allow-option. Higher is better for bot auto-approve:
|
|
160
|
+
* 4 — always allow all sessions / forever
|
|
161
|
+
* 3 — allow for this session (preferred default)
|
|
162
|
+
* 2 — allow always (unscoped)
|
|
163
|
+
* 1 — allow once / approve / yes
|
|
164
|
+
* 0 — not an allow option (reject/deny)
|
|
165
|
+
*/
|
|
166
|
+
function allowScore(o: { name: string; kind?: string }): number {
|
|
167
|
+
const k = `${o.kind ?? ""} ${o.name}`.toLowerCase();
|
|
168
|
+
if (/reject|deny|cancel|no\b|block/.test(k)) return 0;
|
|
169
|
+
if (/all.?sessions|always_allow_all|forever|unrestricted/.test(k)) return 4;
|
|
170
|
+
if (/this.?session|session|allow_session|always_allow_session/.test(k)) return 3;
|
|
171
|
+
if (/always|allow_always|allow.?all\b/.test(k)) return 2;
|
|
172
|
+
if (/allow|approve|yes|once|ok\b/.test(k)) return 1;
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Pick the best allow option, preferring "this session" / "always" so the agent
|
|
178
|
+
* stops re-prompting. Falls back to cancelled only when no allow option exists.
|
|
179
|
+
*/
|
|
180
|
+
export function autoDecideSession(params: RequestPermissionParams): PermissionOutcome {
|
|
181
|
+
let best: { optionId: string; score: number } | undefined;
|
|
182
|
+
for (const o of params.options) {
|
|
183
|
+
const score = allowScore(o);
|
|
184
|
+
if (score <= 0) continue;
|
|
185
|
+
if (!best || score > best.score) best = { optionId: o.optionId, score };
|
|
186
|
+
}
|
|
187
|
+
if (best) return { outcome: { outcome: "selected", optionId: best.optionId } };
|
|
188
|
+
// Last resort: first option if present (agent convention: allow first).
|
|
189
|
+
const first = params.options[0];
|
|
190
|
+
return first
|
|
191
|
+
? { outcome: { outcome: "selected", optionId: first.optionId } }
|
|
148
192
|
: { outcome: { outcome: "cancelled" } };
|
|
149
193
|
}
|
|
@@ -233,14 +233,14 @@ export class ReauthController {
|
|
|
233
233
|
return (
|
|
234
234
|
"\u{1F510} Sign in to Grok\n\n" +
|
|
235
235
|
"Grok signs in with your xAI account (SuperGrok / X Premium+).\n\n" +
|
|
236
|
-
"\u2022 \u{1F511} Sign in \u2014 runs `grok login
|
|
236
|
+
"\u2022 \u{1F511} Sign in \u2014 runs `grok login --device-auth` (no browser); open the link/code here to approve.\n" +
|
|
237
237
|
"\u2022 \u{1F4E5} Import existing \u2014 use a `grok login` already done on this machine."
|
|
238
238
|
);
|
|
239
239
|
case "login": {
|
|
240
|
-
const lines = ["\u{1F511} Signing in to Grok\u2026", ""];
|
|
240
|
+
const lines = ["\u{1F511} Signing in to Grok (device code, no browser)\u2026", ""];
|
|
241
241
|
if (s.url) lines.push(`\u{1F517} Open this link to approve:\n${s.url}`, "");
|
|
242
242
|
if (s.code) lines.push(`\u{1F522} Verification code: ${s.code}`, "");
|
|
243
|
-
if (!s.url && !s.code) lines.push("Starting sign-in
|
|
243
|
+
if (!s.url && !s.code) lines.push("Starting headless device-code sign-in\u2026", "");
|
|
244
244
|
lines.push(`${loader} Waiting\u2026`);
|
|
245
245
|
return lines.join("\n");
|
|
246
246
|
}
|
package/src/config.ts
CHANGED
|
@@ -99,6 +99,13 @@ export interface AppConfig {
|
|
|
99
99
|
* --agent flag headlessly). */
|
|
100
100
|
agent?: string;
|
|
101
101
|
trustAllTools: boolean;
|
|
102
|
+
/**
|
|
103
|
+
* Auto-approve ACP `session/request_permission` prompts (prefer "allow for
|
|
104
|
+
* this session"). Defaults true — Telegram bots shouldn't block on tool
|
|
105
|
+
* approvals. Set false (and GROK_TRUST_ALL_TOOLS=false) for interactive
|
|
106
|
+
* Approve/Deny buttons.
|
|
107
|
+
*/
|
|
108
|
+
autoApprovePermissions: boolean;
|
|
102
109
|
projectRoots: string[];
|
|
103
110
|
streamThrottleMs: number;
|
|
104
111
|
messageBatchMs: number;
|
|
@@ -183,6 +190,8 @@ export function loadConfig(): AppConfig {
|
|
|
183
190
|
maxToolRounds: num(process.env.GROK_MAX_TOOL_ROUNDS, 400),
|
|
184
191
|
agent: process.env.GROK_AGENT?.trim() || undefined,
|
|
185
192
|
trustAllTools: bool(process.env.GROK_TRUST_ALL_TOOLS, true),
|
|
193
|
+
// Default true: auto-approve with session-scope when the agent still asks.
|
|
194
|
+
autoApprovePermissions: bool(process.env.AUTO_APPROVE_PERMISSIONS, true),
|
|
186
195
|
projectRoots: [...new Set(roots)],
|
|
187
196
|
streamThrottleMs: num(process.env.STREAM_THROTTLE_MS, 1500),
|
|
188
197
|
messageBatchMs: nonNegNum(process.env.MESSAGE_BATCH_MS, 800),
|
package/src/grok/client.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
|
14
14
|
import { EventEmitter } from "node:events";
|
|
15
15
|
import { createLogger } from "../logger.js";
|
|
16
|
+
import { hasLogin } from "../app/grok-credentials.js";
|
|
16
17
|
import { contextWindowFor, DEFAULT_MODEL, KNOWN_MODELS } from "./models.js";
|
|
17
18
|
import { PROGRESS_DIRECTIVE } from "../render/progress.js";
|
|
18
19
|
import { SessionLog } from "./session-log.js";
|
|
@@ -76,6 +77,47 @@ function shortJson(v: unknown): string {
|
|
|
76
77
|
}
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
/** Auth methods that open a browser / interactive UI — never use from the bot. */
|
|
81
|
+
const BROWSER_AUTH_RE = /grok\.com|browser|oauth|interactive|web.?login/i;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Pick a headless-safe auth method. Prefer `cached_token` (auth.json) so
|
|
85
|
+
* multi-account rotation works by swapping that file; then `xai.api_key` when
|
|
86
|
+
* an API key is configured. Never falls back to browser methods.
|
|
87
|
+
*/
|
|
88
|
+
export function pickHeadlessAuthMethod(
|
|
89
|
+
methods: Array<{ id: string; name?: string }>,
|
|
90
|
+
hasApiKey: boolean,
|
|
91
|
+
): string | undefined {
|
|
92
|
+
const ids = methods.map((m) => m.id);
|
|
93
|
+
const safe = (id: string) => !BROWSER_AUTH_RE.test(id) && !/login|sign.?in/i.test(id);
|
|
94
|
+
if (ids.includes("cached_token") && safe("cached_token")) return "cached_token";
|
|
95
|
+
if (hasApiKey && ids.includes("xai.api_key") && safe("xai.api_key")) return "xai.api_key";
|
|
96
|
+
// Any other non-browser, non-key method the agent advertises.
|
|
97
|
+
return ids.find((id) => safe(id) && id !== "xai.api_key");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Auto-pick an allow option for permission requests (prefer session/always). */
|
|
101
|
+
function pickAllowOption(
|
|
102
|
+
opts: Array<{ optionId: string; name?: string; kind?: string }>,
|
|
103
|
+
): PermissionOutcome {
|
|
104
|
+
let best: { optionId: string; score: number } | undefined;
|
|
105
|
+
for (const o of opts) {
|
|
106
|
+
const k = `${o.kind ?? ""} ${o.name ?? ""}`.toLowerCase();
|
|
107
|
+
let score = 0;
|
|
108
|
+
if (/reject|deny|cancel|no\b|block/.test(k)) score = 0;
|
|
109
|
+
else if (/all.?sessions|always_allow_all|forever/.test(k)) score = 4;
|
|
110
|
+
else if (/this.?session|session|allow_session/.test(k)) score = 3;
|
|
111
|
+
else if (/always|allow_always|allow.?all\b/.test(k)) score = 2;
|
|
112
|
+
else if (/allow|approve|yes|once|ok\b/.test(k)) score = 1;
|
|
113
|
+
if (score > 0 && (!best || score > best.score)) best = { optionId: o.optionId, score };
|
|
114
|
+
}
|
|
115
|
+
if (best) return { outcome: { outcome: "selected", optionId: best.optionId } };
|
|
116
|
+
return opts[0]
|
|
117
|
+
? { outcome: { outcome: "selected", optionId: opts[0].optionId } }
|
|
118
|
+
: { outcome: { outcome: "cancelled" } };
|
|
119
|
+
}
|
|
120
|
+
|
|
79
121
|
export interface GrokClientOptions {
|
|
80
122
|
grokCliPath: string;
|
|
81
123
|
workspace: string;
|
|
@@ -162,9 +204,10 @@ export class GrokClient extends EventEmitter {
|
|
|
162
204
|
|
|
163
205
|
private async connect(): Promise<void> {
|
|
164
206
|
// `--always-approve` is a `grok agent` option (not `grok agent stdio`),
|
|
165
|
-
// so it must come before the `stdio` subcommand. `--no-
|
|
166
|
-
//
|
|
167
|
-
|
|
207
|
+
// so it must come before the `stdio` subcommand. `--no-leader` keeps auth
|
|
208
|
+
// process-local so swapping ~/.grok/auth.json + restart actually picks up
|
|
209
|
+
// the new token. `--no-auto-update` was removed in grok 0.2.x (exit 2).
|
|
210
|
+
const args = ["agent", "--no-leader"];
|
|
168
211
|
if (this.opts.trustAllTools) args.push("--always-approve");
|
|
169
212
|
args.push("stdio");
|
|
170
213
|
|
|
@@ -197,7 +240,7 @@ export class GrokClient extends EventEmitter {
|
|
|
197
240
|
const init = (await this.request("initialize", {
|
|
198
241
|
protocolVersion: 1,
|
|
199
242
|
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
|
|
200
|
-
clientInfo: { name: "grok-telegram-bot", version: "2.
|
|
243
|
+
clientInfo: { name: "grok-telegram-bot", version: "2.2.0" },
|
|
201
244
|
})) as InitializeResult;
|
|
202
245
|
|
|
203
246
|
this.agentInfo = init.agentInfo ?? { name: "grok" };
|
|
@@ -206,19 +249,29 @@ export class GrokClient extends EventEmitter {
|
|
|
206
249
|
this.subagents = [];
|
|
207
250
|
this.pendingStages = [];
|
|
208
251
|
|
|
209
|
-
// Authenticate
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
252
|
+
// Authenticate headlessly only. NEVER pick browser methods (e.g. "grok.com")
|
|
253
|
+
// — those open a browser and hang/kill the bot host. Prefer cached_token
|
|
254
|
+
// from ~/.grok/auth.json, then xai.api_key when configured.
|
|
255
|
+
this.authMethodId = pickHeadlessAuthMethod(init.authMethods ?? [], !!this.opts.apiKey);
|
|
256
|
+
if (!this.authMethodId) {
|
|
257
|
+
// Never fall back to browser methods (e.g. grok.com) — that opens a
|
|
258
|
+
// browser and freezes headless hosts. Boot unauthenticated so /reauth works.
|
|
259
|
+
log.warn(
|
|
260
|
+
"No headless Grok auth method available. Run `grok login` / /reauth, or set XAI_API_KEY. " +
|
|
261
|
+
"Refusing browser-based auth methods.",
|
|
262
|
+
);
|
|
263
|
+
} else {
|
|
218
264
|
try {
|
|
219
265
|
await this.request("authenticate", { methodId: this.authMethodId, _meta: { headless: true } });
|
|
220
266
|
} catch (e) {
|
|
221
|
-
|
|
267
|
+
const msg = (e as Error).message;
|
|
268
|
+
log.warn(`authenticate (${this.authMethodId}) failed: ${msg}`);
|
|
269
|
+
// If a login (or API key) is present, auth should have worked — surface
|
|
270
|
+
// the error so account switch/rotation doesn't silently keep a dead agent.
|
|
271
|
+
// If nothing is configured yet, soft-fail so the bot can still boot for /reauth.
|
|
272
|
+
if (hasLogin() || this.opts.apiKey) {
|
|
273
|
+
throw new Error(`Grok authenticate (${this.authMethodId}) failed: ${msg}`);
|
|
274
|
+
}
|
|
222
275
|
}
|
|
223
276
|
}
|
|
224
277
|
log.info(`connected: ${this.agentInfo?.name ?? "grok"} ${this.agentInfo?.version ?? ""}`.trim());
|
|
@@ -519,11 +572,9 @@ export class GrokClient extends EventEmitter {
|
|
|
519
572
|
if (method === "session/request_permission" && this.permissionHandler) {
|
|
520
573
|
result = await this.permissionHandler(params as unknown as RequestPermissionParams);
|
|
521
574
|
} else if (method === "session/request_permission") {
|
|
522
|
-
// No handler
|
|
523
|
-
const opts = (params.options as Array<{ optionId: string }>) ?? [];
|
|
524
|
-
result = opts
|
|
525
|
-
? { outcome: { outcome: "selected", optionId: opts[0].optionId } }
|
|
526
|
-
: { outcome: { outcome: "cancelled" } };
|
|
575
|
+
// No handler: auto-approve, preferring session-scope / always options.
|
|
576
|
+
const opts = (params.options as Array<{ optionId: string; name?: string; kind?: string }>) ?? [];
|
|
577
|
+
result = pickAllowOption(opts);
|
|
527
578
|
} else {
|
|
528
579
|
// We advertise no fs/terminal capabilities, so the agent shouldn't ask.
|
|
529
580
|
throw new GrokError(`unsupported client method: ${method}`, -32601);
|