Skip to content

Commit 10328c4

Browse files
CDVolvikScriptedAlchemy
authored andcommitted
feat: read the Grok Bot app session on Windows
1 parent 9b034ce commit 10328c4

4 files changed

Lines changed: 193 additions & 19 deletions

File tree

‎.changeset/windows-app-session.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"grok-bot-cli": patch
3+
---
4+
5+
Use the signed-in Grok Bot app session on Windows (`%APPDATA%\\Grok Bot`, DPAPI Safe Storage).

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Manage [Grok Bot](https://cursor.com/help/grok-bot/plans) agents, groups, and me
1414
npm install --global grok-bot-cli
1515
```
1616

17-
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`).
17+
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.
1818

1919
## Use
2020

‎src/app-session.js‎

Lines changed: 98 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import { join } from "node:path";
66

77
// Chromium OSCrypt: "v10" = fixed password (macOS: the Keychain password; Linux: "peanuts"),
88
// "v11" = Linux Secret Service password. macOS stretches with 1003 PBKDF2 rounds, Linux with 1.
9+
// Windows v10 uses AES-256-GCM with a DPAPI-wrapped key from Local State (not PBKDF2).
910
const SAFE_STORAGE_PREFIX_V10 = "v10";
1011
const SAFE_STORAGE_PREFIX_V11 = "v11";
12+
const SAFE_STORAGE_PREFIX_V10_BUF = Buffer.from(SAFE_STORAGE_PREFIX_V10);
1113
const LINUX_BASIC_TEXT_PASSWORD = "peanuts";
12-
const SUPPORTED_PLATFORMS = new Set(["darwin", "linux"]);
14+
const SUPPORTED_PLATFORMS = new Set(["darwin", "linux", "win32"]);
1315

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

83-
export function grokBotGatewayDescriptorPath(home = homedir(), platform = process.platform, env = process.env) {
85+
// Windows Chromium Safe Storage: "v10" + 12-byte nonce + AES-256-GCM ciphertext + 16-byte tag.
86+
export function decryptWindowsSafeStorageString(encryptedBase64, key) {
87+
const encrypted = Buffer.from(encryptedBase64, "base64");
88+
if (!encrypted.subarray(0, 3).equals(SAFE_STORAGE_PREFIX_V10_BUF)) {
89+
throw new Error("Unsupported Grok Bot Safe Storage format.");
90+
}
91+
92+
const decipher = crypto.createDecipheriv(
93+
"aes-256-gcm",
94+
key,
95+
encrypted.subarray(3, 15),
96+
);
97+
decipher.setAuthTag(encrypted.subarray(-16));
98+
return Buffer.concat([
99+
decipher.update(encrypted.subarray(15, -16)),
100+
decipher.final(),
101+
]).toString("utf8");
102+
}
103+
104+
function grokBotAppDataPath(home, platform, env = {}) {
105+
if (platform === "win32") {
106+
const appData = env.APPDATA || join(home, "AppData/Roaming");
107+
return join(appData, "Grok Bot");
108+
}
84109
if (platform === "linux") {
85110
const configHome = env.XDG_CONFIG_HOME || join(home, ".config");
86-
return join(configHome, "Grok Bot/gateway-descriptor.json");
111+
return join(configHome, "Grok Bot");
87112
}
88-
return join(
89-
home,
90-
"Library/Application Support/Grok Bot/gateway-descriptor.json",
91-
);
113+
return join(home, "Library/Application Support/Grok Bot");
114+
}
115+
116+
export function grokBotGatewayDescriptorPath(home = homedir(), platform = process.platform, env = process.env) {
117+
return join(grokBotAppDataPath(home, platform, env), "gateway-descriptor.json");
118+
}
119+
120+
function sessionEnv({ env = process.env, appData } = {}) {
121+
// Windows tests pass appData directly; empty string clears APPDATA to exercise the home fallback.
122+
if (appData !== undefined) return { ...env, APPDATA: appData };
123+
return env;
92124
}
93125

94126
export function hasGrokBotGatewaySession({
95127
platform = process.platform,
96128
home = homedir(),
97129
env = process.env,
130+
appData,
98131
} = {}) {
99-
return SUPPORTED_PLATFORMS.has(platform) && existsSync(grokBotGatewayDescriptorPath(home, platform, env));
132+
return (
133+
SUPPORTED_PLATFORMS.has(platform) &&
134+
existsSync(grokBotGatewayDescriptorPath(home, platform, sessionEnv({ env, appData })))
135+
);
100136
}
101137

102138
function readKeychainPassword(platform = process.platform) {
@@ -114,27 +150,72 @@ function readKeychainPassword(platform = process.platform) {
114150
).trimEnd();
115151
}
116152

153+
function unprotectWithDpapi(blob) {
154+
const script =
155+
"Add-Type -AssemblyName System.Security; " +
156+
"$blob = [Convert]::FromBase64String([Console]::In.ReadToEnd().Trim()); " +
157+
"[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Unprotect($blob, $null, 'CurrentUser'))";
158+
const out = execFileSync(
159+
join(
160+
process.env.SystemRoot ?? "C:\\Windows",
161+
"System32/WindowsPowerShell/v1.0/powershell.exe",
162+
),
163+
["-NoProfile", "-NonInteractive", "-Command", script],
164+
{ input: blob.toString("base64"), encoding: "utf8" },
165+
);
166+
return Buffer.from(out.trim(), "base64");
167+
}
168+
169+
function readWindowsSafeStorageKey(home, env, unprotectData) {
170+
const path = join(grokBotAppDataPath(home, "win32", env), "Local State");
171+
const encryptedKey = existsSync(path)
172+
? JSON.parse(readFileSync(path, "utf8")).os_crypt?.encrypted_key
173+
: null;
174+
const blob = Buffer.from(
175+
typeof encryptedKey === "string" ? encryptedKey : "",
176+
"base64",
177+
);
178+
if (blob.subarray(0, 5).toString("latin1") !== "DPAPI") {
179+
throw new GrokBotGatewaySessionError(
180+
"MISSING_SAFE_STORAGE_KEY",
181+
"Grok Bot Local State has no Safe Storage key.",
182+
);
183+
}
184+
return unprotectData(blob.subarray(5));
185+
}
186+
117187
export function loadGrokBotGatewaySession({
118188
platform = process.platform,
119189
home = homedir(),
120190
env = process.env,
191+
appData,
121192
getKeychainPassword = readKeychainPassword,
193+
unprotectData = unprotectWithDpapi,
122194
} = {}) {
123195
if (!SUPPORTED_PLATFORMS.has(platform)) return null;
124196

125-
const path = grokBotGatewayDescriptorPath(home, platform, env);
197+
const effectiveEnv = sessionEnv({ env, appData });
198+
const path = grokBotGatewayDescriptorPath(home, platform, effectiveEnv);
126199
if (!existsSync(path)) return null;
127200

128201
const wrapped = JSON.parse(readFileSync(path, "utf8"));
129202
const encrypted = encryptedPayload(wrapped);
130-
const prefix = Buffer.from(encrypted, "base64").subarray(0, 3).toString("latin1");
131-
// Linux v10 is the keyring-less basic_text backend; no secret store to ask.
132-
const needsKeychain = !(platform === "linux" && prefix === SAFE_STORAGE_PREFIX_V10);
133-
const clear = decryptSafeStorageString(
134-
encrypted,
135-
needsKeychain ? getKeychainPassword(platform) : LINUX_BASIC_TEXT_PASSWORD,
136-
platform,
137-
);
203+
let clear;
204+
if (platform === "win32") {
205+
clear = decryptWindowsSafeStorageString(
206+
encrypted,
207+
readWindowsSafeStorageKey(home, effectiveEnv, unprotectData),
208+
);
209+
} else {
210+
const prefix = Buffer.from(encrypted, "base64").subarray(0, 3).toString("latin1");
211+
// Linux v10 is the keyring-less basic_text backend; no secret store to ask.
212+
const needsKeychain = !(platform === "linux" && prefix === SAFE_STORAGE_PREFIX_V10);
213+
clear = decryptSafeStorageString(
214+
encrypted,
215+
needsKeychain ? getKeychainPassword(platform) : LINUX_BASIC_TEXT_PASSWORD,
216+
platform,
217+
);
218+
}
138219
const descriptor = JSON.parse(clear);
139220
if (!descriptor.baseUrl || !descriptor.token) {
140221
throw new GrokBotGatewaySessionError(

‎test/app-session.test.js‎

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import test from "node:test";
77

88
import {
99
decryptSafeStorageString,
10+
decryptWindowsSafeStorageString,
1011
grokBotGatewayDescriptorPath,
1112
hasGrokBotGatewaySession,
1213
inspectGrokBotGatewaySession,
@@ -17,6 +18,12 @@ const ENCRYPTED_DESCRIPTOR =
1718
"djEwddBm+U69UF2IJtIUtedNqMB3bQt7HsRw7MLWRkw/IfnMK+c4czCXq82JKPNsdsP3Bp2fX8HoGPZFsa7k+JOmbIkBanQwl4yiy9v7iOA+mE4rtGqYbYD9jJc+/9YnhcGjvSxCxD8fKbJLbHifTwroGQ==";
1819
const ENCRYPTED_INCOMPLETE_DESCRIPTOR =
1920
"djEwddBm+U69UF2IJtIUtedNqMB3bQt7HsRw7MLWRkw/IfnWb2TrPAofoKysOC2KnKLJ";
21+
const WINDOWS_KEY = Buffer.from("demo-safe-storage-key-32-bytes!!");
22+
const WINDOWS_ENCRYPTED_DESCRIPTOR =
23+
"djEwZGVtby1ub25jZTEyUN+Gpc6/+RfJzyx52epPxU9Qlzo9TQNAJD6QHtsCaTCTBxssiBtRPJzcylXxQTjOJdPlxMjVIjlxWhW7WfcfxPjJiVIr8J3dZZA7J1dF9uSYG6iDLXHAotC+0gDB7k81WdJmrbloEC+sslxz+AeU6VW3B5E5yYepIfkEdw==";
24+
const WINDOWS_LOCAL_STATE = {
25+
os_crypt: { encrypted_key: Buffer.from("DPAPIdemo-dpapi-blob").toString("base64") },
26+
};
2027

2128
function writeWrappedDescriptor(wrapped) {
2229
const home = mkdtempSync(join(tmpdir(), "gbot-home-"));
@@ -29,6 +36,25 @@ function writeWrappedDescriptor(wrapped) {
2936
return home;
3037
}
3138

39+
function writeWindowsAppData(
40+
localState,
41+
appData = mkdtempSync(join(tmpdir(), "gbot-appdata-")),
42+
) {
43+
const dir = join(appData, "Grok Bot");
44+
mkdirSync(dir, { recursive: true });
45+
writeFileSync(
46+
join(dir, "gateway-descriptor.json"),
47+
JSON.stringify({ version: 2, entries: { primary: { encrypted: WINDOWS_ENCRYPTED_DESCRIPTOR } } }),
48+
);
49+
writeFileSync(join(dir, "Local State"), JSON.stringify(localState));
50+
return appData;
51+
}
52+
53+
function unprotectDemoKey(blob) {
54+
assert.equal(blob.toString("utf8"), "demo-dpapi-blob");
55+
return WINDOWS_KEY;
56+
}
57+
3258
test("decrypts an Electron Safe Storage v10 string", () => {
3359
const clear = decryptSafeStorageString(
3460
ENCRYPTED_DESCRIPTOR,
@@ -315,7 +341,7 @@ test("does not probe app credentials on unsupported platforms", () => {
315341
let keychainRead = false;
316342

317343
const session = loadGrokBotGatewaySession({
318-
platform: "win32",
344+
platform: "freebsd",
319345
home: "/tmp/unused",
320346
getKeychainPassword: () => {
321347
keychainRead = true;
@@ -326,3 +352,65 @@ test("does not probe app credentials on unsupported platforms", () => {
326352
assert.equal(session, null);
327353
assert.equal(keychainRead, false);
328354
});
355+
356+
test("decrypts a Windows Electron Safe Storage v10 string", () => {
357+
const clear = decryptWindowsSafeStorageString(
358+
WINDOWS_ENCRYPTED_DESCRIPTOR,
359+
WINDOWS_KEY,
360+
);
361+
362+
assert.deepEqual(JSON.parse(clear), {
363+
baseUrl: "https://box.example",
364+
token: "gateway-token",
365+
headers: { "x-anyrun-network-token": "route-token" },
366+
});
367+
});
368+
369+
test("loads the signed-in Grok Bot gateway on Windows", () => {
370+
const appData = writeWindowsAppData(WINDOWS_LOCAL_STATE);
371+
372+
const session = loadGrokBotGatewaySession({
373+
platform: "win32",
374+
home: "/tmp/unused",
375+
appData,
376+
unprotectData: unprotectDemoKey,
377+
});
378+
379+
assert.deepEqual(session, {
380+
gatewayUrl: "https://box.example",
381+
gatewayToken: "gateway-token",
382+
headers: { "x-anyrun-network-token": "route-token" },
383+
});
384+
});
385+
386+
test("falls back to AppData/Roaming under home when APPDATA is unset", () => {
387+
const home = mkdtempSync(join(tmpdir(), "gbot-home-"));
388+
writeWindowsAppData(WINDOWS_LOCAL_STATE, join(home, "AppData/Roaming"));
389+
390+
const session = loadGrokBotGatewaySession({
391+
platform: "win32",
392+
home,
393+
appData: "",
394+
unprotectData: unprotectDemoKey,
395+
});
396+
397+
assert.equal(session.gatewayUrl, "https://box.example");
398+
});
399+
400+
test("reports a Windows app session without a Safe Storage key", () => {
401+
const appData = writeWindowsAppData({ os_crypt: {} });
402+
403+
const status = inspectGrokBotGatewaySession({
404+
platform: "win32",
405+
home: "/tmp/unused",
406+
appData,
407+
unprotectData: unprotectDemoKey,
408+
});
409+
410+
assert.deepEqual(status, {
411+
present: true,
412+
usable: false,
413+
code: "MISSING_SAFE_STORAGE_KEY",
414+
error: "Grok Bot Local State has no Safe Storage key.",
415+
});
416+
});

0 commit comments

Comments
 (0)