Skip to content
Merged
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/linux-app-session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"grok-bot-cli": patch
---

Use the signed-in Grok Bot app session on Linux: read `~/.config/Grok Bot/gateway-descriptor.json` (honouring `XDG_CONFIG_HOME`), decrypt Chromium `v11` payloads with the Secret Service password via `secret-tool` and `v10` payloads with the basic-text key, and apply Linux's single PBKDF2 round instead of the macOS 1003.
2 changes: 1 addition & 1 deletion 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 18+ and the Grok Bot macOS app. Open Grok Bot and sign in once; `gbot` automatically uses the app's encrypted session and routing credentials. No token copying is required.
Requires Node.js 18+ and the Grok Bot desktop app on macOS or Linux. 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`).

## Use

Expand Down
50 changes: 40 additions & 10 deletions src/app-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";

const SAFE_STORAGE_PREFIX = Buffer.from("v10");
// Chromium OSCrypt: "v10" = fixed password (macOS: the Keychain password; Linux: "peanuts"),
// "v11" = Linux Secret Service password. macOS stretches with 1003 PBKDF2 rounds, Linux with 1.
const SAFE_STORAGE_PREFIX_V10 = "v10";
const SAFE_STORAGE_PREFIX_V11 = "v11";
const LINUX_BASIC_TEXT_PASSWORD = "peanuts";
const SUPPORTED_PLATFORMS = new Set(["darwin", "linux"]);

export class GrokBotGatewaySessionError extends Error {
constructor(code, message) {
Expand Down Expand Up @@ -49,13 +54,21 @@ function encryptedPayload(wrapped) {
return encrypted;
}

export function decryptSafeStorageString(encryptedBase64, password) {
function safeStorageKey(password, platform) {
const iterations = platform === "linux" ? 1 : 1003;
return crypto.pbkdf2Sync(password, "saltysalt", iterations, 16, "sha1");
}

export function decryptSafeStorageString(encryptedBase64, password, platform = "darwin") {
const encrypted = Buffer.from(encryptedBase64, "base64");
if (!encrypted.subarray(0, 3).equals(SAFE_STORAGE_PREFIX)) {
const prefix = encrypted.subarray(0, 3).toString("latin1");
const linuxBasicText = platform === "linux" && prefix === SAFE_STORAGE_PREFIX_V10;
const keyring = prefix === SAFE_STORAGE_PREFIX_V10 || (platform === "linux" && prefix === SAFE_STORAGE_PREFIX_V11);
if (!keyring) {
throw new Error("Unsupported Grok Bot Safe Storage format.");
}

const key = crypto.pbkdf2Sync(password, "saltysalt", 1003, 16, "sha1");
const key = safeStorageKey(linuxBasicText ? LINUX_BASIC_TEXT_PASSWORD : password, platform);
const decipher = crypto.createDecipheriv(
"aes-128-cbc",
key,
Expand All @@ -67,7 +80,11 @@ export function decryptSafeStorageString(encryptedBase64, password) {
]).toString("utf8");
}

export function grokBotGatewayDescriptorPath(home = homedir()) {
export function grokBotGatewayDescriptorPath(home = homedir(), platform = process.platform, env = process.env) {
if (platform === "linux") {
const configHome = env.XDG_CONFIG_HOME || join(home, ".config");
return join(configHome, "Grok Bot/gateway-descriptor.json");
}
return join(
home,
"Library/Application Support/Grok Bot/gateway-descriptor.json",
Expand All @@ -77,11 +94,19 @@ export function grokBotGatewayDescriptorPath(home = homedir()) {
export function hasGrokBotGatewaySession({
platform = process.platform,
home = homedir(),
env = process.env,
} = {}) {
return platform === "darwin" && existsSync(grokBotGatewayDescriptorPath(home));
return SUPPORTED_PLATFORMS.has(platform) && existsSync(grokBotGatewayDescriptorPath(home, platform, env));
}

function readKeychainPassword() {
function readKeychainPassword(platform = process.platform) {
if (platform === "linux") {
return execFileSync(
"secret-tool",
["lookup", "xdg:schema", "chrome_libsecret_os_crypt_password_v2", "application", "Grok Bot"],
{ encoding: "utf8" },
).trimEnd();
}
return execFileSync(
"/usr/bin/security",
["find-generic-password", "-w", "-s", "Grok Bot Safe Storage"],
Expand All @@ -92,18 +117,23 @@ function readKeychainPassword() {
export function loadGrokBotGatewaySession({
platform = process.platform,
home = homedir(),
env = process.env,
getKeychainPassword = readKeychainPassword,
} = {}) {
if (platform !== "darwin") return null;
if (!SUPPORTED_PLATFORMS.has(platform)) return null;

const path = grokBotGatewayDescriptorPath(home);
const path = grokBotGatewayDescriptorPath(home, platform, env);
if (!existsSync(path)) return null;

const wrapped = JSON.parse(readFileSync(path, "utf8"));
const encrypted = encryptedPayload(wrapped);
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);
const clear = decryptSafeStorageString(
encrypted,
getKeychainPassword(),
needsKeychain ? getKeychainPassword(platform) : LINUX_BASIC_TEXT_PASSWORD,
platform,
);
const descriptor = JSON.parse(clear);
if (!descriptor.baseUrl || !descriptor.token) {
Expand Down
103 changes: 102 additions & 1 deletion test/app-session.test.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import test from "node:test";

import {
decryptSafeStorageString,
grokBotGatewayDescriptorPath,
hasGrokBotGatewaySession,
inspectGrokBotGatewaySession,
loadGrokBotGatewaySession,
} from "../src/app-session.js";
Expand Down Expand Up @@ -210,11 +213,109 @@ test("reports a present but unusable Grok Bot app session", () => {
});
});

test("does not probe macOS credentials on other platforms", () => {
function encryptLinuxSafeStorage(clear, prefix, password) {
// Chromium OSCrypt on Linux: PBKDF2-SHA1, "saltysalt", ONE iteration, AES-128-CBC, IV = 16 spaces.
const key = crypto.pbkdf2Sync(password, "saltysalt", 1, 16, "sha1");
const cipher = crypto.createCipheriv("aes-128-cbc", key, Buffer.alloc(16, 32));
return Buffer.concat([
Buffer.from(prefix, "latin1"),
cipher.update(clear, "utf8"),
cipher.final(),
]).toString("base64");
}

const LINUX_DESCRIPTOR = JSON.stringify({
baseUrl: "https://box.example/",
token: "gateway-token",
headers: { "x-anyrun-network-token": "route-token" },
});

function writeLinuxDescriptor(encrypted, env = {}) {
const home = mkdtempSync(join(tmpdir(), "gbot-home-"));
const descriptorPath = grokBotGatewayDescriptorPath(home, "linux", env);
mkdirSync(dirname(descriptorPath), { recursive: true });
writeFileSync(descriptorPath, JSON.stringify({ version: 2, entries: { primary: { encrypted } } }));
return home;
}

test("loads a Linux v11 descriptor with the Secret Service password", () => {
const home = writeLinuxDescriptor(
encryptLinuxSafeStorage(LINUX_DESCRIPTOR, "v11", "keyring-password"),
);
let platformAsked = null;

const session = loadGrokBotGatewaySession({
platform: "linux",
home,
env: {},
getKeychainPassword: (platform) => {
platformAsked = platform;
return "keyring-password";
},
});

assert.equal(platformAsked, "linux");
assert.deepEqual(session, {
gatewayUrl: "https://box.example",
gatewayToken: "gateway-token",
headers: { "x-anyrun-network-token": "route-token" },
});
});

test("loads a Linux v10 (basic_text) descriptor without touching the keyring", () => {
const home = writeLinuxDescriptor(
encryptLinuxSafeStorage(LINUX_DESCRIPTOR, "v10", "peanuts"),
);
let keychainRead = false;

const session = loadGrokBotGatewaySession({
platform: "linux",
home,
env: {},
getKeychainPassword: () => {
keychainRead = true;
return "unused";
},
});

assert.equal(keychainRead, false);
assert.equal(session.gatewayToken, "gateway-token");
});

test("honours XDG_CONFIG_HOME for the Linux descriptor", () => {
const configHome = mkdtempSync(join(tmpdir(), "gbot-xdg-"));
const env = { XDG_CONFIG_HOME: configHome };
writeLinuxDescriptor(encryptLinuxSafeStorage(LINUX_DESCRIPTOR, "v10", "peanuts"), env);

assert.equal(hasGrokBotGatewaySession({ platform: "linux", home: "/tmp/unused", env }), true);
assert.equal(
grokBotGatewayDescriptorPath("/tmp/unused", "linux", env),
join(configHome, "Grok Bot/gateway-descriptor.json"),
);
});

test("rejects a wrong Secret Service password on Linux as unusable", () => {
const home = writeLinuxDescriptor(
encryptLinuxSafeStorage(LINUX_DESCRIPTOR, "v11", "keyring-password"),
);

const status = inspectGrokBotGatewaySession({
platform: "linux",
home,
env: {},
getKeychainPassword: () => "wrong-password",
});

assert.equal(status.present, true);
assert.equal(status.usable, false);
assert.equal(status.code, "UNUSABLE_SESSION");
});

test("does not probe app credentials on unsupported platforms", () => {
let keychainRead = false;

const session = loadGrokBotGatewaySession({
platform: "win32",
home: "/tmp/unused",
getKeychainPassword: () => {
keychainRead = true;
Expand Down
10 changes: 5 additions & 5 deletions test/doctor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,20 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";

import { grokBotGatewayDescriptorPath } from "../src/app-session.js";

const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url));

test("doctor reports a present but unusable Grok Bot app session", {
skip: process.platform !== "darwin" && "Grok Bot app sessions are macOS-only",
skip: !["darwin", "linux"].includes(process.platform) && "Grok Bot app sessions are macOS/Linux-only",
}, () => {
const home = mkdtempSync(join(tmpdir(), "gbot-doctor-home-"));
const descriptorPath = join(
home,
"Library/Application Support/Grok Bot/gateway-descriptor.json",
);
const descriptorPath = grokBotGatewayDescriptorPath(home, process.platform, {});
mkdirSync(dirname(descriptorPath), { recursive: true });
writeFileSync(descriptorPath, JSON.stringify({ version: 2, entries: {} }));
const env = { ...process.env, HOME: home };
for (const name of [
"XDG_CONFIG_HOME",
"CURSOR_ACCESS_TOKEN",
"GROK_BOT_ACCESS_TOKEN",
"GROK_BOT_GATEWAY_URL",
Expand Down