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/windows-app-session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"grok-bot-cli": minor
---

Use the signed-in Grok Bot app session on Windows.
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 app on macOS 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.

## Use

Expand Down
96 changes: 86 additions & 10 deletions src/app-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,18 +67,55 @@ export function decryptSafeStorageString(encryptedBase64, password) {
]).toString("utf8");
}

export function grokBotGatewayDescriptorPath(home = homedir()) {
// 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)) {
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, appData) {
return platform === "win32"
? join(appData || join(home, "AppData/Roaming"), "Grok Bot")
: join(home, "Library/Application Support/Grok Bot");
}

function isSupportedPlatform(platform) {
return platform === "darwin" || platform === "win32";
}

export function grokBotGatewayDescriptorPath(
home = homedir(),
platform = process.platform,
appData = process.env.APPDATA,
) {
return join(
home,
"Library/Application Support/Grok Bot/gateway-descriptor.json",
grokBotAppDataPath(home, platform, appData),
"gateway-descriptor.json",
);
}

export function hasGrokBotGatewaySession({
platform = process.platform,
home = homedir(),
appData = process.env.APPDATA,
} = {}) {
return platform === "darwin" && existsSync(grokBotGatewayDescriptorPath(home));
return (
isSupportedPlatform(platform) &&
existsSync(grokBotGatewayDescriptorPath(home, platform, appData))
);
}

function readKeychainPassword() {
Expand All @@ -89,22 +126,61 @@ function readKeychainPassword() {
).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, appData, unprotectData) {
const path = join(grokBotAppDataPath(home, "win32", appData), "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(),
appData = process.env.APPDATA,
getKeychainPassword = readKeychainPassword,
unprotectData = unprotectWithDpapi,
} = {}) {
if (platform !== "darwin") return null;
if (!isSupportedPlatform(platform)) return null;

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

const wrapped = JSON.parse(readFileSync(path, "utf8"));
const encrypted = encryptedPayload(wrapped);
const clear = decryptSafeStorageString(
encrypted,
getKeychainPassword(),
);
const clear =
platform === "win32"
? decryptWindowsSafeStorageString(
encrypted,
readWindowsSafeStorageKey(home, appData, unprotectData),
)
: decryptSafeStorageString(encrypted, getKeychainPassword());
const descriptor = JSON.parse(clear);
if (!descriptor.baseUrl || !descriptor.token) {
throw new GrokBotGatewaySessionError(
Expand Down
88 changes: 88 additions & 0 deletions test/app-session.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import test from "node:test";

import {
decryptSafeStorageString,
decryptWindowsSafeStorageString,
inspectGrokBotGatewaySession,
loadGrokBotGatewaySession,
} from "../src/app-session.js";
Expand All @@ -14,6 +15,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 @@ -26,6 +33,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 @@ -225,3 +251,65 @@ test("does not probe macOS credentials on other 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.",
});
});