From 10328c4dcf38f31aea6eeaf6dfd4875f96b05770 Mon Sep 17 00:00:00 2001 From: CDVolvik Date: Thu, 10 Sep 2026 08:41:49 -0700 Subject: [PATCH 1/2] feat: read the Grok Bot app session on Windows --- .changeset/windows-app-session.md | 5 ++ README.md | 2 +- src/app-session.js | 115 +++++++++++++++++++++++++----- test/app-session.test.js | 90 ++++++++++++++++++++++- 4 files changed, 193 insertions(+), 19 deletions(-) create mode 100644 .changeset/windows-app-session.md diff --git a/.changeset/windows-app-session.md b/.changeset/windows-app-session.md new file mode 100644 index 0000000..e4c96d6 --- /dev/null +++ b/.changeset/windows-app-session.md @@ -0,0 +1,5 @@ +--- +"grok-bot-cli": patch +--- + +Use the signed-in Grok Bot app session on Windows (`%APPDATA%\\Grok Bot`, DPAPI Safe Storage). diff --git a/README.md b/README.md index 501295b..dca3a0e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/app-session.js b/src/app-session.js index 0f62695..23ca967 100644 --- a/src/app-session.js +++ b/src/app-session.js @@ -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) { @@ -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) { @@ -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( diff --git a/test/app-session.test.js b/test/app-session.test.js index 191a101..97cff0d 100644 --- a/test/app-session.test.js +++ b/test/app-session.test.js @@ -7,6 +7,7 @@ import test from "node:test"; import { decryptSafeStorageString, + decryptWindowsSafeStorageString, grokBotGatewayDescriptorPath, hasGrokBotGatewaySession, inspectGrokBotGatewaySession, @@ -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-")); @@ -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, @@ -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; @@ -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.", + }); +}); From c96f0f965c590d39dd86cd31f817de0a8d0042b7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 15 Sep 2026 01:02:41 +0000 Subject: [PATCH 2/2] fix: fall through to access token when app session is unusable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A present but broken Grok Bot descriptor (e.g. Windows Local State missing) no longer blocks CURSOR_ACCESS_TOKEN → EnsureSandBox. --- src/gateway.js | 10 ++- test/connect-gateway.test.js | 124 +++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 test/connect-gateway.test.js diff --git a/src/gateway.js b/src/gateway.js index 46eba00..daf9d7a 100644 --- a/src/gateway.js +++ b/src/gateway.js @@ -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" }), diff --git a/test/connect-gateway.test.js b/test/connect-gateway.test.js new file mode 100644 index 0000000..6197796 --- /dev/null +++ b/test/connect-gateway.test.js @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; + +import { grokBotGatewayDescriptorPath } from "../src/app-session.js"; +import { connectGateway } from "../src/gateway.js"; + +function withEnv(values, fn) { + const prev = {}; + for (const key of Object.keys(values)) { + prev[key] = process.env[key]; + const v = values[key]; + if (v == null) delete process.env[key]; + else process.env[key] = v; + } + try { + return fn(); + } finally { + for (const key of Object.keys(values)) { + if (prev[key] === undefined) delete process.env[key]; + else process.env[key] = prev[key]; + } + } +} + +function writeBrokenAppSession() { + const home = mkdtempSync(join(tmpdir(), "gbot-connect-home-")); + const env = + process.platform === "linux" + ? { XDG_CONFIG_HOME: join(home, ".config") } + : process.platform === "win32" + ? { APPDATA: join(home, "AppData/Roaming") } + : {}; + const descriptorPath = grokBotGatewayDescriptorPath(home, process.platform, { + ...process.env, + ...env, + HOME: home, + }); + mkdirSync(dirname(descriptorPath), { recursive: true }); + // Present but unusable — empty v2 entries (and no Local State on Windows). + writeFileSync(descriptorPath, JSON.stringify({ version: 2, entries: {} })); + return { home, env }; +} + +test("unusable app session falls through to CURSOR_ACCESS_TOKEN EnsureSandBox", async (t) => { + if (!["darwin", "linux", "win32"].includes(process.platform)) { + t.skip("app session platforms only"); + return; + } + + const { home, env } = writeBrokenAppSession(); + const calls = []; + t.mock.method(globalThis, "fetch", async (url, options) => { + calls.push({ url: String(url), body: options.body }); + return new Response( + JSON.stringify({ + gatewayUrl: "https://box.cursor.sh", + gatewayToken: "from-ensure", + }), + { status: 200 }, + ); + }); + + await withEnv( + { + HOME: home, + USERPROFILE: home, + CURSOR_ACCESS_TOKEN: "cursor-access-token", + GROK_BOT_GATEWAY_URL: null, + GROK_BOT_GATEWAY_TOKEN: null, + SAND_HOST_GATEWAY_URL: null, + SAND_HOST_GATEWAY_TOKEN: null, + SAND_GATEWAY_TOKEN: null, + GROK_BOT_ACCESS_TOKEN: null, + SAND_ACCESS_TOKEN: null, + GROK_BOT_ALLOW_ANY_GATEWAY: null, + GROK_BOT_ALLOW_LOCAL_GATEWAY: null, + ...env, + }, + async () => { + const session = await connectGateway(); + assert.equal(session.gatewayUrl, "https://box.cursor.sh"); + assert.equal(session.gatewayToken, "from-ensure"); + assert.equal(calls.length, 1); + assert.match(calls[0].url, /EnsureSandBox/); + }, + ); +}); + +test("unusable app session without access token surfaces the session error", async (t) => { + if (!["darwin", "linux", "win32"].includes(process.platform)) { + t.skip("app session platforms only"); + return; + } + + const { home, env } = writeBrokenAppSession(); + await withEnv( + { + HOME: home, + USERPROFILE: home, + CURSOR_ACCESS_TOKEN: null, + GROK_BOT_ACCESS_TOKEN: null, + SAND_ACCESS_TOKEN: null, + GROK_BOT_GATEWAY_URL: null, + GROK_BOT_GATEWAY_TOKEN: null, + SAND_HOST_GATEWAY_URL: null, + SAND_HOST_GATEWAY_TOKEN: null, + SAND_GATEWAY_TOKEN: null, + ...env, + }, + async () => { + await assert.rejects( + connectGateway(), + (error) => { + assert.equal(error.name, "GatewayError"); + assert.match(error.message, /no saved gateway entries/i); + return true; + }, + ); + }, + ); +});