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/gateway-descriptor-v3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"grok-bot-cli": patch
---

Read Grok Bot gateway descriptor version 3 (same `entries` layout as version 2), which current Grok Bot desktop builds write. The multiple-entries safety check is unchanged.
5 changes: 5 additions & 0 deletions .changeset/hibernated-read-reconnect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"grok-bot-cli": patch
---

Reconnect allowlisted read-only gateway calls once when the AnyRun proxy explicitly reports a hibernated pod. Use the selected desktop account's Safe Storage credentials and installed app version to obtain fresh routing credentials, coalesce concurrent refreshes, and retain the new route in memory without rewriting desktop-owned files. Refuse account changes, preserve explicit gateway overrides and credential host checks, and never automatically replay mutations or generic transport failures.
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.

222 changes: 187 additions & 35 deletions artifact/bin/gbot-flight.mjs

Large diffs are not rendered by default.

222 changes: 187 additions & 35 deletions artifact/bin/gbot.mjs

Large diffs are not rendered by default.

214 changes: 183 additions & 31 deletions artifact/mcp/mcp-grok-bot-b8c2461e-flight.mjs

Large diffs are not rendered by default.

214 changes: 183 additions & 31 deletions artifact/mcp/mcp-grok-bot-b8c2461e.mjs

Large diffs are not rendered by default.

210 changes: 181 additions & 29 deletions artifact/scripts/gbot-relay.mjs

Large diffs are not rendered by default.

70 changes: 68 additions & 2 deletions src/core/app-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@ 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) {
// v3 keeps the v2 `entries` layout (adds `savedAtMs`; the payload adds `vncProxy`, unused here).
if (wrapped.version === 2 || wrapped.version === 3) {
const entries = Object.values(wrapped.entries ?? {});
if (entries.length === 0) {
throw new GrokBotGatewaySessionError(
Expand Down Expand Up @@ -184,6 +185,71 @@ function readWindowsSafeStorageKey(home, env, unprotectData) {
return unprotectData(blob.subarray(5));
}

// Read only the explicitly selected account; never guess from the account roster.
function activeAppAccount({ home = homedir(), platform = process.platform, env = process.env, appData } = {}) {
if (!SUPPORTED_PLATFORMS.has(platform)) return null;
const path = join(grokBotAppDataPath(home, platform, sessionEnv({ env, appData })), "sand-secrets.json");
if (!existsSync(path)) return null;
const stored = JSON.parse(readFileSync(path, "utf8"));
if (stored.version != null && stored.version !== 1) {
throw new GrokBotGatewaySessionError("UNSUPPORTED_CREDENTIALS", "Unsupported Grok Bot account storage version.");
}
const accounts = JSON.parse(stored["cursor-accounts"] ?? "null");
if (typeof accounts?.active !== "string" || !Object.hasOwn(accounts.accounts ?? {}, accounts.active)) return null;
return { key: accounts.active, account: accounts.accounts[accounts.active] };
}

export function grokBotActiveAccountKey(options = {}) {
return activeAppAccount(options)?.key ?? null;
}

export function grokBotAppVersion({ home = homedir(), platform = process.platform, env = process.env, appData } = {}) {
if (!SUPPORTED_PLATFORMS.has(platform)) return null;
const path = join(grokBotAppDataPath(home, platform, sessionEnv({ env, appData })), "desktop-status.json");
if (!existsSync(path)) return null;
try {
const version = JSON.parse(readFileSync(path, "utf8")).appVersion;
return typeof version === "string" && /^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(version) ? version : null;
} catch {
return null;
}
}

export function loadGrokBotAppCredentials({
home = homedir(), platform = process.platform, env = process.env, appData,
expectedAccountKey,
getKeychainPassword = readKeychainPassword, unprotectData = unprotectWithDpapi,
} = {}) {
const effectiveEnv = sessionEnv({ env, appData });
const selected = activeAppAccount({ home, platform, env: effectiveEnv });
if (!selected) return null;
if (expectedAccountKey !== undefined && selected.key !== expectedAccountKey) {
throw new GrokBotGatewaySessionError("ACCOUNT_CHANGED", "Grok Bot active account changed; reconnect refused.");
}
const encryptedToken = selected.account?.["cursor-access-token"];
if (typeof encryptedToken !== "string" || !encryptedToken) {
throw new GrokBotGatewaySessionError("INCOMPLETE_CREDENTIALS", "Grok Bot active account has no access token.");
}
const windowsKey = platform === "win32" ? readWindowsSafeStorageKey(home, effectiveEnv, unprotectData) : null;
const decrypt = (encrypted) => {
if (windowsKey) return decryptWindowsSafeStorageString(encrypted, windowsKey);
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);
};
const accessToken = decrypt(encryptedToken).trim();
const encryptedTeam = selected.account["cursor-selected-team-id"];
const teamId = encryptedTeam == null ? undefined : decrypt(encryptedTeam);
if (!accessToken) {
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, accountKey: selected.key, ...(teamId === undefined ? {} : { teamId }) };
}

export function loadGrokBotGatewaySession({
platform = process.platform,
home = homedir(),
Expand Down
66 changes: 56 additions & 10 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 { grokBotActiveAccountKey, hasGrokBotGatewaySession, loadGrokBotAppCredentials, 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 All @@ -17,6 +17,8 @@ class GatewayError extends Error {
// ponytail: fixed 30 s deadline and buffered byte cap; upgrade path is per-method budgets plus streaming reads.
const GATEWAY_TIMEOUT_MS = 30000;
export const GATEWAY_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
const READ_ONLY_METHODS = new Set(["listAgents", "getAgentWorkflows", "getAgentTranscriptTail", "getAgentThread"]);
const reconnectStates = new WeakMap();

function assertGatewayActive(signal) {
if (signal?.aborted) {
Expand Down Expand Up @@ -60,10 +62,10 @@ function gatewayOverride() {
return null;
}

function sessionFromApp() {
function sessionFromApp(options) {
let loaded;
try {
loaded = loadGrokBotGatewaySession();
loaded = loadGrokBotGatewaySession(options);
} catch (error) {
// Descriptor present but unusable (e.g. Windows Local State missing). Fall
// through to CURSOR_ACCESS_TOKEN → EnsureSandBox when that token is set.
Expand Down Expand Up @@ -124,7 +126,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 +135,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 All @@ -154,26 +156,61 @@ async function ensureSandbox(accessToken, { signal } = {}) {
return { gatewayUrl: assertAllowedCredentialUrl(String(gatewayUrl).replace(/\/$/, ""), { kind: "gateway" }), gatewayToken: String(gatewayToken), gatewayHeaders: mergeGatewayHeaders(headersFromEnsureSandbox(body), headersFromEnv()) };
}

export async function connectGateway({ signal } = {}) {
export async function connectGateway({ signal, appSessionOptions } = {}) {
assertGatewayActive(signal);
const override = gatewayOverride();
if (override) return override;
const fromApp = sessionFromApp();
if (fromApp) return fromApp;
const fromApp = sessionFromApp(appSessionOptions);
if (fromApp) {
// Malformed account metadata disables reconnect, not the cached session.
let accountKey = null;
try { accountKey = grokBotActiveAccountKey(appSessionOptions); } catch { /* no safe reconnect identity */ }
if (accountKey) reconnectStates.set(fromApp, { accountKey, appSessionOptions, generation: 0, pending: null });
return fromApp;
}
const token = accessTokenFromEnv();
if (!token) {
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 });
const session = await ensureSandbox(token, { signal });
reconnectStates.set(session, { accessToken: token, generation: 0, pending: null });
return session;
}

async function refreshGateway(session, state, failedGeneration, signal) {
assertGatewayActive(signal);
if (state.generation !== failedGeneration) return;
if (!state.pending) {
const refresh = async () => {
const credentials = state.accountKey
? loadGrokBotAppCredentials({ ...state.appSessionOptions, expectedAccountKey: state.accountKey })
: { accessToken: state.accessToken };
if (!credentials?.accessToken) {
throw new GatewayError("No matching credentials available to reconnect the hibernated gateway.");
}
const fresh = await ensureSandbox(credentials.accessToken, { signal, teamId: credentials.teamId });
if (state.accountKey && grokBotActiveAccountKey(state.appSessionOptions) !== state.accountKey) {
throw new GatewayError("Grok Bot active account changed during reconnect; refreshed route discarded.");
}
assertGatewayActive(signal);
Object.assign(session, fresh);
state.generation++;
};
state.pending = refresh().finally(() => { state.pending = null; });
}
await state.pending;
assertGatewayActive(signal);
}

async function gatewayCall(session, method, body = {}, { signal } = {}) {
async function gatewayCall(session, method, body = {}, { signal, reconnected = false } = {}) {
assertGatewayActive(signal);
const base = assertAllowedCredentialUrl(session.gatewayUrl, { kind: "gateway" });
const url = base + "/api/" + method;
// Cancellation can abort read/auth preflights. Once a prompt request starts,
// observe its actual receipt (or timeout) instead of losing certainty on stop.
const prompt = method === "sendPrompt";
const reconnectState = reconnectStates.get(session);
const generation = reconnectState?.generation;
const options = {
method: "POST",
redirect: "error",
Expand All @@ -192,6 +229,15 @@ async function gatewayCall(session, method, body = {}, { signal } = {}) {
}
if (!prompt) assertGatewayActive(signal);
if (!res.ok) {
if (
!reconnected && READ_ONLY_METHODS.has(method) && reconnectState &&
res.status === 417 &&
res.headers.get("x-anyrun-error-source") === "anyrun-proxy" &&
res.headers.get("x-anyrun-failure-reason") === "pod_hibernated"
) {
await refreshGateway(session, reconnectState, generation, signal);
return gatewayCall(session, method, body, { signal, reconnected: true });
}
const detail = data.message || data.error || data.raw || res.statusText;
throw new GatewayError(method + " failed: " + res.status + " " + redactSecrets(String(detail).slice(0, 300)), { status: res.status, method });
}
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