Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/active-account-reconnect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"grok-bot-cli": patch
---

Reconnect from the active encrypted Grok Bot account when the cached gateway descriptor is absent, and use the installed macOS app version for backend requests.
5 changes: 5 additions & 0 deletions .changeset/gateway-descriptor-v3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"grok-bot-cli": patch
---

Support version 3 Grok Bot gateway descriptors using the existing encrypted entry reader, retaining rejection of empty, ambiguous, and missing-payload entries.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Manage [Grok Bot](https://cursor.com/help/grok-bot/plans) agents, groups, and me
npm install --global grok-bot-cli
```

Requires Node.js 22.19.0+ and the Grok Bot desktop app on macOS, Linux, or Windows. Open Grok Bot and sign in once; `gbot` automatically uses the app's encrypted session and routing credentials. No token copying is required. On Linux the app keeps its session under `~/.config/Grok Bot` (or `$XDG_CONFIG_HOME`); when it is stored in the system keyring, `gbot` reads the key with `secret-tool` (package `libsecret-tools`). On Windows the session lives under `%APPDATA%\\Grok Bot` and decrypts with the app's DPAPI-wrapped Safe Storage key.
Requires Node.js 22.19.0+ and the Grok Bot desktop app on macOS, Linux, or Windows. Open Grok Bot and sign in once; `gbot` automatically uses the app's encrypted session and routing credentials. No token copying is required. If the app removes its cached gateway route while the cloud computer sleeps, the CLI uses the active account in `sand-secrets.json` to request a fresh route through `EnsureSandBox`. Signed-out and inactive saved accounts are never selected. On macOS, backend requests use the installed Grok Bot app version; `SAND_CLIENT_VERSION` remains an explicit override. On Linux the app keeps its session under `~/.config/Grok Bot` (or `$XDG_CONFIG_HOME`); when it is stored in the system keyring, `gbot` reads the key with `secret-tool` (package `libsecret-tools`). On Windows the session lives under `%APPDATA%\\Grok Bot` and decrypts with the app's DPAPI-wrapped Safe Storage key.

## Use

Expand Down Expand Up @@ -355,7 +355,7 @@ the short summary. Unknown cursors set `gapReset: true`; repeat that call with
`full:true` to inspect the bounded reset snapshot.

Auth resolves exactly as for `gbot`: `GROK_BOT_GATEWAY_URL` + `GROK_BOT_GATEWAY_TOKEN`,
then the Grok Bot app session, then `CURSOR_ACCESS_TOKEN`. The MCP server therefore
then the Grok Bot cached gateway session, then `CURSOR_ACCESS_TOKEN`, then the active Grok Bot account credentials. The MCP server therefore
needs outbound HTTPS to the gateway host and read access to the app-session file
(`~/.config/Grok Bot` on Linux, `~/Library/Application Support/Grok Bot` on macOS).
A sandbox that blocks network egress or hides the home directory makes `gbot_send`
Expand Down
2 changes: 1 addition & 1 deletion artifact/agent-bundle.compile-evidence.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion artifact/agent-bundle.manifest.json

Large diffs are not rendered by default.

162 changes: 124 additions & 38 deletions artifact/bin/gbot-flight.mjs

Large diffs are not rendered by default.

162 changes: 124 additions & 38 deletions artifact/bin/gbot.mjs

Large diffs are not rendered by default.

152 changes: 118 additions & 34 deletions artifact/mcp/mcp-grok-bot-b8c2461e-flight.mjs

Large diffs are not rendered by default.

152 changes: 118 additions & 34 deletions artifact/mcp/mcp-grok-bot-b8c2461e.mjs

Large diffs are not rendered by default.

148 changes: 116 additions & 32 deletions artifact/scripts/gbot-relay.mjs

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions src/cli/doctor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Agent } from '@agent-bundle/runtime';
import type { CliRouteConfig, CliRouteProps } from 'agent-bundle';
import { z } from 'zod';

import { inspectGrokBotGatewaySession } from '../core/app-session.js';
import { hasGrokBotAppCredentials, inspectGrokBotGatewaySession } from '../core/app-session.js';
import { hasGatewayAuth } from '../core/gateway.js';
import {
defaultCandidateRoots,
Expand Down Expand Up @@ -44,7 +44,7 @@ export default async function doctor({ input }: CliRouteProps<typeof inputSchema
} catch (err) {
if (!(err instanceof StoreError)) throw err;
}
const note = 'Live roster is on the box. Prefer CURSOR_ACCESS_TOKEN then EnsureSandBox then POST gateway /api/*.';
const note = 'Live roster is on the box. A missing cached route can reconnect through the active Grok Bot account.';
const gatewayAuthPresent = hasGatewayAuth();
const grokBotAppSession = inspectGrokBotGatewaySession();
const sessionJson = {
Expand All @@ -65,6 +65,7 @@ export default async function doctor({ input }: CliRouteProps<typeof inputSchema
`resolved: ${resolved ?? '(none)'}`,
`gateway auth: ${gatewayAuthPresent ? 'present' : 'no'}`,
sessionLine,
`Grok Bot active account credentials: ${hasGrokBotAppCredentials() ? 'present' : 'not found'}`,
'found:',
found.length ? found.map((p) => ` ${p}`).join('\n') : ' (none)',
'candidates:',
Expand Down
80 changes: 62 additions & 18 deletions src/core/app-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ class GrokBotGatewaySessionError extends Error {
}

function encryptedPayload(wrapped) {
if (wrapped.version != null && wrapped.version !== 1 && wrapped.version !== 2) {
if (wrapped.version != null && wrapped.version !== 1 && wrapped.version !== 2 && wrapped.version !== 3) {
throw new GrokBotGatewaySessionError(
"UNSUPPORTED_VERSION",
`Unsupported Grok Bot gateway descriptor version ${wrapped.version}.`,
);
}
let encrypted;
if (wrapped.version === 2) {
if (wrapped.version === 2 || wrapped.version === 3) {
const entries = Object.values(wrapped.entries ?? {});
if (entries.length === 0) {
throw new GrokBotGatewaySessionError(
Expand Down Expand Up @@ -135,6 +135,65 @@ export function hasGrokBotGatewaySession({
);
}

export function grokBotAppVersion({ home = homedir(), platform = process.platform } = {}) {
if (platform !== "darwin") return null;
for (const root of [join(home, "Applications"), "/Applications"]) {
const plist = join(root, "Grok Bot.app/Contents/Info.plist");
if (!existsSync(plist)) continue;
const version = execFileSync("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleShortVersionString", plist], { encoding: "utf8" }).trim();
if (/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(version)) return version;
}
return null;
}

// The app may delete its cached gateway route while the box sleeps. The active
// account remains signed in; use only that account to request a fresh route.
function activeGrokBotAccount({ home = homedir(), platform = process.platform, env = process.env } = {}) {
if (!SUPPORTED_PLATFORMS.has(platform)) return null;
const path = join(grokBotAppDataPath(home, platform, env), "sand-secrets.json");
if (!existsSync(path)) return null;
try {
const stored = JSON.parse(readFileSync(path, "utf8"));
if (stored.version != null && stored.version !== 1) return null;
const accounts = JSON.parse(stored["cursor-accounts"] ?? "null");
if (typeof accounts?.active !== "string" || !Object.hasOwn(accounts.accounts ?? {}, accounts.active)) return null;
const active = accounts.accounts[accounts.active];
return typeof active?.["cursor-access-token"] === "string" && active["cursor-access-token"] ? active : null;
} catch {
return null;
}
}

export function hasGrokBotAppCredentials(options = {}) {
return activeGrokBotAccount(options) !== null;
}

function decryptAppSecret(encrypted, { platform, home, env, getKeychainPassword, unprotectData }) {
if (platform === "win32") {
return decryptWindowsSafeStorageString(encrypted, readWindowsSafeStorageKey(home, env, unprotectData));
}
const prefix = Buffer.from(encrypted, "base64").subarray(0, 3).toString("latin1");
const password = platform === "linux" && prefix === SAFE_STORAGE_PREFIX_V10
? LINUX_BASIC_TEXT_PASSWORD : getKeychainPassword(platform);
return decryptSafeStorageString(encrypted, password, platform);
}

export function loadGrokBotAppCredentials({
home = homedir(), platform = process.platform, env = process.env,
getKeychainPassword = readKeychainPassword, unprotectData = unprotectWithDpapi,
} = {}) {
const active = activeGrokBotAccount({ home, platform, env });
if (!active) return null;
const decrypt = encrypted => decryptAppSecret(encrypted, { platform, home, env, getKeychainPassword, unprotectData });
const accessToken = decrypt(active["cursor-access-token"]);
const teamId = active["cursor-selected-team-id"] == null ? undefined : decrypt(active["cursor-selected-team-id"]);
if (!accessToken.trim()) throw new GrokBotGatewaySessionError("INCOMPLETE_CREDENTIALS", "Grok Bot active account has no access token.");
if (teamId !== undefined && (!/^[1-9][0-9]*$/.test(teamId) || !Number.isSafeInteger(Number(teamId)))) {
throw new GrokBotGatewaySessionError("INVALID_TEAM", "Grok Bot active account has an invalid selected team.");
}
return { accessToken, ...(teamId === undefined ? {} : { teamId }) };
}

function readKeychainPassword(platform = process.platform) {
if (platform === "linux") {
return execFileSync(
Expand Down Expand Up @@ -200,22 +259,7 @@ export function loadGrokBotGatewaySession({

const wrapped = JSON.parse(readFileSync(path, "utf8"));
const encrypted = encryptedPayload(wrapped);
let clear;
if (platform === "win32") {
clear = decryptWindowsSafeStorageString(
encrypted,
readWindowsSafeStorageKey(home, effectiveEnv, unprotectData),
);
} else {
const prefix = Buffer.from(encrypted, "base64").subarray(0, 3).toString("latin1");
// Linux v10 is the keyring-less basic_text backend; no secret store to ask.
const needsKeychain = !(platform === "linux" && prefix === SAFE_STORAGE_PREFIX_V10);
clear = decryptSafeStorageString(
encrypted,
needsKeychain ? getKeychainPassword(platform) : LINUX_BASIC_TEXT_PASSWORD,
platform,
);
}
const clear = decryptAppSecret(encrypted, { platform, home, env: effectiveEnv, getKeychainPassword, unprotectData });
const descriptor = JSON.parse(clear);
if (!descriptor.baseUrl || !descriptor.token) {
throw new GrokBotGatewaySessionError(
Expand Down
10 changes: 6 additions & 4 deletions src/core/gateway.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { ensureSandboxHeaders, headersFromEnsureSandbox, headersFromEnv, mergeGatewayHeaders, normalizeHeaderMap, requestHeaders } from "./headers.js";
import { hasGrokBotGatewaySession, loadGrokBotGatewaySession } from "./app-session.js";
import { hasGrokBotAppCredentials, loadGrokBotAppCredentials, hasGrokBotGatewaySession, loadGrokBotGatewaySession } from "./app-session.js";
import { AVATAR_COLORS, AVATAR_SHAPES, MAX_GROUP_MEMBERS } from "./store.js";
import { assertAllowedCredentialUrl, redactSecrets } from "./url-policy.js";
import { grokApproval, grokApprovalResponseSchema } from "./grok-approvals.js";
Expand Down Expand Up @@ -79,7 +79,7 @@ function sessionFromApp() {
}

export function hasGatewayAuth() {
return Boolean(gatewayOverride() || accessTokenFromEnv() || hasGrokBotGatewaySession());
return Boolean(gatewayOverride() || accessTokenFromEnv() || hasGrokBotGatewaySession() || hasGrokBotAppCredentials());
}

async function readTextCapped(res, maxBytes) {
Expand Down Expand Up @@ -124,7 +124,7 @@ function pick(obj, ...keys) {
return undefined;
}

async function ensureSandbox(accessToken, { signal } = {}) {
async function ensureSandbox(accessToken, { signal, teamId } = {}) {
assertGatewayActive(signal);
const url = assertAllowedCredentialUrl(backendBase(), { kind: "backend" }) + "/aiserver.v1.GrokBotService/EnsureSandBox";
let res, body;
Expand All @@ -133,7 +133,7 @@ async function ensureSandbox(accessToken, { signal } = {}) {
method: "POST",
redirect: "error",
signal: gatewayDeadline(signal),
headers: ensureSandboxHeaders(accessToken),
headers: { ...ensureSandboxHeaders(accessToken), ...(teamId === undefined ? {} : { "x-cursor-team-id": teamId }) },
body: "{}",
});
body = await readJson(res);
Expand Down Expand Up @@ -162,6 +162,8 @@ export async function connectGateway({ signal } = {}) {
if (fromApp) return fromApp;
const token = accessTokenFromEnv();
if (!token) {
const credentials = loadGrokBotAppCredentials();
if (credentials) return ensureSandbox(credentials.accessToken, { signal, teamId: credentials.teamId });
throw new GatewayError("Set CURSOR_ACCESS_TOKEN, or GROK_BOT_GATEWAY_URL + GROK_BOT_GATEWAY_TOKEN. Do not use a Cursor dashboard API key.");
}
return ensureSandbox(token, { signal });
Expand Down
4 changes: 3 additions & 1 deletion src/core/headers.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { grokBotAppVersion } from "./app-session.js";

export function normalizeHeaderMap(obj) {
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return {};
const out = {};
Expand Down Expand Up @@ -47,7 +49,7 @@ export function ensureSandboxHeaders(accessToken) {
"connect-protocol-version": "1",
authorization: "Bearer " + accessToken,
"x-cursor-client-type": "sand",
"x-cursor-client-version": process.env.SAND_CLIENT_VERSION || "0.20.0",
"x-cursor-client-version": process.env.SAND_CLIENT_VERSION || grokBotAppVersion() || "0.20.0",
"x-sand-box-namespace": process.env.SAND_BOX_NAMESPACE || "prod",
};
}
Loading