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/windows-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 Windows (`%APPDATA%\\Grok Bot`, DPAPI Safe Storage).
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 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`).
Requires Node.js 18+ 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.

## Use

Expand Down
115 changes: 98 additions & 17 deletions src/app-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import { join } from "node:path";

// 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.
// Windows v10 uses AES-256-GCM with a DPAPI-wrapped key from Local State (not PBKDF2).
const SAFE_STORAGE_PREFIX_V10 = "v10";
const SAFE_STORAGE_PREFIX_V11 = "v11";
const SAFE_STORAGE_PREFIX_V10_BUF = Buffer.from(SAFE_STORAGE_PREFIX_V10);
const LINUX_BASIC_TEXT_PASSWORD = "peanuts";
const SUPPORTED_PLATFORMS = new Set(["darwin", "linux"]);
const SUPPORTED_PLATFORMS = new Set(["darwin", "linux", "win32"]);

export class GrokBotGatewaySessionError extends Error {
constructor(code, message) {
Expand Down Expand Up @@ -80,23 +82,57 @@ export function decryptSafeStorageString(encryptedBase64, password, platform = "
]).toString("utf8");
}

export function grokBotGatewayDescriptorPath(home = homedir(), platform = process.platform, env = process.env) {
// Windows Chromium Safe Storage: "v10" + 12-byte nonce + AES-256-GCM ciphertext + 16-byte tag.
export function decryptWindowsSafeStorageString(encryptedBase64, key) {
const encrypted = Buffer.from(encryptedBase64, "base64");
if (!encrypted.subarray(0, 3).equals(SAFE_STORAGE_PREFIX_V10_BUF)) {
throw new Error("Unsupported Grok Bot Safe Storage format.");
}

const decipher = crypto.createDecipheriv(
"aes-256-gcm",
key,
encrypted.subarray(3, 15),
);
decipher.setAuthTag(encrypted.subarray(-16));
return Buffer.concat([
decipher.update(encrypted.subarray(15, -16)),
decipher.final(),
]).toString("utf8");
}

function grokBotAppDataPath(home, platform, env = {}) {
if (platform === "win32") {
const appData = env.APPDATA || join(home, "AppData/Roaming");
return join(appData, "Grok Bot");
}
if (platform === "linux") {
const configHome = env.XDG_CONFIG_HOME || join(home, ".config");
return join(configHome, "Grok Bot/gateway-descriptor.json");
return join(configHome, "Grok Bot");
}
return join(
home,
"Library/Application Support/Grok Bot/gateway-descriptor.json",
);
return join(home, "Library/Application Support/Grok Bot");
}

export function grokBotGatewayDescriptorPath(home = homedir(), platform = process.platform, env = process.env) {
return join(grokBotAppDataPath(home, platform, env), "gateway-descriptor.json");
}

function sessionEnv({ env = process.env, appData } = {}) {
// Windows tests pass appData directly; empty string clears APPDATA to exercise the home fallback.
if (appData !== undefined) return { ...env, APPDATA: appData };
return env;
}

export function hasGrokBotGatewaySession({
platform = process.platform,
home = homedir(),
env = process.env,
appData,
} = {}) {
return SUPPORTED_PLATFORMS.has(platform) && existsSync(grokBotGatewayDescriptorPath(home, platform, env));
return (
SUPPORTED_PLATFORMS.has(platform) &&
existsSync(grokBotGatewayDescriptorPath(home, platform, sessionEnv({ env, appData })))
);
}

function readKeychainPassword(platform = process.platform) {
Expand All @@ -114,27 +150,72 @@ function readKeychainPassword(platform = process.platform) {
).trimEnd();
}

function unprotectWithDpapi(blob) {
const script =
"Add-Type -AssemblyName System.Security; " +
"$blob = [Convert]::FromBase64String([Console]::In.ReadToEnd().Trim()); " +
"[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Unprotect($blob, $null, 'CurrentUser'))";
const out = execFileSync(
join(
process.env.SystemRoot ?? "C:\\Windows",
"System32/WindowsPowerShell/v1.0/powershell.exe",
),
["-NoProfile", "-NonInteractive", "-Command", script],
{ input: blob.toString("base64"), encoding: "utf8" },
);
return Buffer.from(out.trim(), "base64");
}

function readWindowsSafeStorageKey(home, env, unprotectData) {
const path = join(grokBotAppDataPath(home, "win32", env), "Local State");
const encryptedKey = existsSync(path)
? JSON.parse(readFileSync(path, "utf8")).os_crypt?.encrypted_key
: null;
const blob = Buffer.from(
typeof encryptedKey === "string" ? encryptedKey : "",
"base64",
);
if (blob.subarray(0, 5).toString("latin1") !== "DPAPI") {
throw new GrokBotGatewaySessionError(
"MISSING_SAFE_STORAGE_KEY",
"Grok Bot Local State has no Safe Storage key.",
);
}
return unprotectData(blob.subarray(5));
}

export function loadGrokBotGatewaySession({
platform = process.platform,
home = homedir(),
env = process.env,
appData,
getKeychainPassword = readKeychainPassword,
unprotectData = unprotectWithDpapi,
} = {}) {
if (!SUPPORTED_PLATFORMS.has(platform)) return null;

const path = grokBotGatewayDescriptorPath(home, platform, env);
const effectiveEnv = sessionEnv({ env, appData });
const path = grokBotGatewayDescriptorPath(home, platform, effectiveEnv);
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,
needsKeychain ? getKeychainPassword(platform) : LINUX_BASIC_TEXT_PASSWORD,
platform,
);
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 descriptor = JSON.parse(clear);
if (!descriptor.baseUrl || !descriptor.token) {
throw new GrokBotGatewaySessionError(
Expand Down
10 changes: 9 additions & 1 deletion src/gateway.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,15 @@ function gatewayOverride() {
}

function sessionFromApp() {
const loaded = loadGrokBotGatewaySession();
let loaded;
try {
loaded = loadGrokBotGatewaySession();
} catch (error) {
// Descriptor present but unusable (e.g. Windows Local State missing). Fall
// through to CURSOR_ACCESS_TOKEN → EnsureSandBox when that token is set.
if (accessTokenFromEnv()) return null;
throw error instanceof Error ? new GatewayError(error.message) : error;
}
if (!loaded) return null;
return {
gatewayUrl: assertAllowedCredentialUrl(loaded.gatewayUrl, { kind: "gateway" }),
Expand Down
90 changes: 89 additions & 1 deletion test/app-session.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import test from "node:test";

import {
decryptSafeStorageString,
decryptWindowsSafeStorageString,
grokBotGatewayDescriptorPath,
hasGrokBotGatewaySession,
inspectGrokBotGatewaySession,
Expand All @@ -17,6 +18,12 @@ const ENCRYPTED_DESCRIPTOR =
"djEwddBm+U69UF2IJtIUtedNqMB3bQt7HsRw7MLWRkw/IfnMK+c4czCXq82JKPNsdsP3Bp2fX8HoGPZFsa7k+JOmbIkBanQwl4yiy9v7iOA+mE4rtGqYbYD9jJc+/9YnhcGjvSxCxD8fKbJLbHifTwroGQ==";
const ENCRYPTED_INCOMPLETE_DESCRIPTOR =
"djEwddBm+U69UF2IJtIUtedNqMB3bQt7HsRw7MLWRkw/IfnWb2TrPAofoKysOC2KnKLJ";
const WINDOWS_KEY = Buffer.from("demo-safe-storage-key-32-bytes!!");
const WINDOWS_ENCRYPTED_DESCRIPTOR =
"djEwZGVtby1ub25jZTEyUN+Gpc6/+RfJzyx52epPxU9Qlzo9TQNAJD6QHtsCaTCTBxssiBtRPJzcylXxQTjOJdPlxMjVIjlxWhW7WfcfxPjJiVIr8J3dZZA7J1dF9uSYG6iDLXHAotC+0gDB7k81WdJmrbloEC+sslxz+AeU6VW3B5E5yYepIfkEdw==";
const WINDOWS_LOCAL_STATE = {
os_crypt: { encrypted_key: Buffer.from("DPAPIdemo-dpapi-blob").toString("base64") },
};

function writeWrappedDescriptor(wrapped) {
const home = mkdtempSync(join(tmpdir(), "gbot-home-"));
Expand All @@ -29,6 +36,25 @@ function writeWrappedDescriptor(wrapped) {
return home;
}

function writeWindowsAppData(
localState,
appData = mkdtempSync(join(tmpdir(), "gbot-appdata-")),
) {
const dir = join(appData, "Grok Bot");
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, "gateway-descriptor.json"),
JSON.stringify({ version: 2, entries: { primary: { encrypted: WINDOWS_ENCRYPTED_DESCRIPTOR } } }),
);
writeFileSync(join(dir, "Local State"), JSON.stringify(localState));
return appData;
}

function unprotectDemoKey(blob) {
assert.equal(blob.toString("utf8"), "demo-dpapi-blob");
return WINDOWS_KEY;
}

test("decrypts an Electron Safe Storage v10 string", () => {
const clear = decryptSafeStorageString(
ENCRYPTED_DESCRIPTOR,
Expand Down Expand Up @@ -315,7 +341,7 @@ test("does not probe app credentials on unsupported platforms", () => {
let keychainRead = false;

const session = loadGrokBotGatewaySession({
platform: "win32",
platform: "freebsd",
home: "/tmp/unused",
getKeychainPassword: () => {
keychainRead = true;
Expand All @@ -326,3 +352,65 @@ test("does not probe app credentials on unsupported platforms", () => {
assert.equal(session, null);
assert.equal(keychainRead, false);
});

test("decrypts a Windows Electron Safe Storage v10 string", () => {
const clear = decryptWindowsSafeStorageString(
WINDOWS_ENCRYPTED_DESCRIPTOR,
WINDOWS_KEY,
);

assert.deepEqual(JSON.parse(clear), {
baseUrl: "https://box.example",
token: "gateway-token",
headers: { "x-anyrun-network-token": "route-token" },
});
});

test("loads the signed-in Grok Bot gateway on Windows", () => {
const appData = writeWindowsAppData(WINDOWS_LOCAL_STATE);

const session = loadGrokBotGatewaySession({
platform: "win32",
home: "/tmp/unused",
appData,
unprotectData: unprotectDemoKey,
});

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

test("falls back to AppData/Roaming under home when APPDATA is unset", () => {
const home = mkdtempSync(join(tmpdir(), "gbot-home-"));
writeWindowsAppData(WINDOWS_LOCAL_STATE, join(home, "AppData/Roaming"));

const session = loadGrokBotGatewaySession({
platform: "win32",
home,
appData: "",
unprotectData: unprotectDemoKey,
});

assert.equal(session.gatewayUrl, "https://box.example");
});

test("reports a Windows app session without a Safe Storage key", () => {
const appData = writeWindowsAppData({ os_crypt: {} });

const status = inspectGrokBotGatewaySession({
platform: "win32",
home: "/tmp/unused",
appData,
unprotectData: unprotectDemoKey,
});

assert.deepEqual(status, {
present: true,
usable: false,
code: "MISSING_SAFE_STORAGE_KEY",
error: "Grok Bot Local State has no Safe Storage key.",
});
});
Loading