@vibetechnologies/chrome-sync 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts ADDED
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @vibetechnologies/chrome-sync CLI
4
+ *
5
+ * Sync browser cookies from local Chrome to OpenClaw cloud browser.
6
+ *
7
+ * Usage:
8
+ * chrome-sync login
9
+ * chrome-sync login --token <ADMIN_SECRET>
10
+ * chrome-sync push --domains google.com,github.com
11
+ * chrome-sync push --profile "Profile 1"
12
+ * chrome-sync profiles
13
+ * chrome-sync logout
14
+ */
15
+
16
+ import { Command } from "commander";
17
+ import { extractStorageState } from "./extract.js";
18
+ import { loginViaBrowser, loginWithToken, pushCookies, loadAuth, clearAuth } from "./api.js";
19
+
20
+ const program = new Command();
21
+
22
+ program
23
+ .name("chrome-sync")
24
+ .description(
25
+ "Sync browser cookies and sessions from local Chrome to OpenClaw cloud browser"
26
+ )
27
+ .version("0.4.0");
28
+
29
+ program
30
+ .command("login")
31
+ .description("Authenticate with OpenClaw (opens browser)")
32
+ .option("--token <token>", "Use a direct API token instead of browser auth")
33
+ .option(
34
+ "--api-url <url>",
35
+ "OpenClaw API URL",
36
+ "https://console.openclaw.vibebrowser.app"
37
+ )
38
+ .action(async (opts) => {
39
+ try {
40
+ let config;
41
+ if (opts.token) {
42
+ config = await loginWithToken(opts.apiUrl, opts.token);
43
+ } else {
44
+ config = await loginViaBrowser(opts.apiUrl);
45
+ }
46
+ console.log(`✓ Authenticated as ${config.username}`);
47
+ if (config.subdomain) {
48
+ console.log(` Tenant: ${config.subdomain}`);
49
+ }
50
+ console.log(` API: ${config.apiUrl}`);
51
+ } catch (err: any) {
52
+ console.error(`✗ Login failed: ${err.message}`);
53
+ process.exit(1);
54
+ }
55
+ });
56
+
57
+ program
58
+ .command("push")
59
+ .description("Extract and push cookies to cloud browser")
60
+ .option(
61
+ "--domains <domains>",
62
+ "Comma-separated domains to sync (omit for all cookies)"
63
+ )
64
+ .option("--profile <profile>", "Chrome profile name", "Default")
65
+ .option("--subdomain <subdomain>", "Target tenant subdomain")
66
+ .option("--dry-run", "Extract cookies but don't push", false)
67
+ .action(async (opts) => {
68
+ try {
69
+ // Check auth first
70
+ let auth = loadAuth();
71
+ if (!auth) {
72
+ console.log("Not authenticated. Starting login...\n");
73
+ auth = await loginViaBrowser();
74
+ console.log(`✓ Authenticated as ${auth.username}\n`);
75
+ }
76
+
77
+ const domains = opts.domains
78
+ ? opts.domains
79
+ .split(",")
80
+ .map((d: string) => d.trim())
81
+ .map((d: string) => (d.startsWith(".") ? d : `.${d}`))
82
+ : [];
83
+
84
+ const label = domains.length > 0 ? domains.join(", ") : "ALL";
85
+ console.log(
86
+ `Extracting cookies for: ${label} (profile: ${opts.profile})`
87
+ );
88
+
89
+ const storageState = await extractStorageState(domains, opts.profile);
90
+ console.log(` Found ${storageState.cookies.length} cookies`);
91
+
92
+ if (storageState.cookies.length === 0) {
93
+ console.log(" No cookies found. Make sure you're logged into these sites in Chrome.");
94
+ process.exit(1);
95
+ }
96
+
97
+ if (opts.dryRun) {
98
+ console.log("\n Dry run — cookies extracted but not pushed.");
99
+ console.log(" Domains covered:");
100
+ const domainSet = new Set(storageState.cookies.map((c) => c.domain));
101
+ for (const d of domainSet) {
102
+ const count = storageState.cookies.filter((c) => c.domain === d).length;
103
+ console.log(` ${d}: ${count} cookies`);
104
+ }
105
+ return;
106
+ }
107
+
108
+ console.log(" Pushing to cloud browser...");
109
+ const result = await pushCookies(storageState, {
110
+ subdomain: opts.subdomain,
111
+ });
112
+
113
+ console.log(`✓ Injected ${result.injected} cookies into cloud browser`);
114
+ if (result.errors.length > 0) {
115
+ console.warn(` Warnings: ${result.errors.join(", ")}`);
116
+ }
117
+ } catch (err: any) {
118
+ console.error(`✗ Failed: ${err.message}`);
119
+ process.exit(1);
120
+ }
121
+ });
122
+
123
+ program
124
+ .command("profiles")
125
+ .description("List available Chrome profiles")
126
+ .action(() => {
127
+ const { getChromeProfileDir } = require("./extract.js") as typeof import("./extract.js");
128
+ const { readdirSync } = require("node:fs");
129
+ const { dirname } = require("node:path");
130
+ const { existsSync } = require("node:fs");
131
+
132
+ const defaultDir = getChromeProfileDir("Default");
133
+ const chromeDir = dirname(defaultDir);
134
+
135
+ if (!existsSync(chromeDir)) {
136
+ console.log("Chrome profile directory not found.");
137
+ return;
138
+ }
139
+
140
+ const profiles = readdirSync(chromeDir, { withFileTypes: true })
141
+ .filter(
142
+ (d: any) =>
143
+ d.isDirectory() &&
144
+ (d.name === "Default" || d.name.startsWith("Profile "))
145
+ )
146
+ .map((d: any) => d.name);
147
+
148
+ console.log("Available Chrome profiles:");
149
+ for (const p of profiles) {
150
+ console.log(` ${p}`);
151
+ }
152
+ });
153
+
154
+ program
155
+ .command("logout")
156
+ .description("Clear saved authentication")
157
+ .action(() => {
158
+ clearAuth();
159
+ console.log("✓ Logged out");
160
+ });
161
+
162
+ program.parse();
package/src/extract.ts ADDED
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Chrome cookie extraction — reads and decrypts cookies from local Chrome profile.
3
+ *
4
+ * Supports: macOS (Keychain), Linux (libsecret / fallback "peanuts"), Windows (DPAPI via AES-GCM).
5
+ */
6
+
7
+ import { execSync } from "node:child_process";
8
+ import { copyFileSync, existsSync, mkdtempSync } from "node:fs";
9
+ import { homedir, platform, tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import crypto from "node:crypto";
12
+
13
+ export interface Cookie {
14
+ name: string;
15
+ value: string;
16
+ domain: string;
17
+ path: string;
18
+ expires: number;
19
+ httpOnly: boolean;
20
+ secure: boolean;
21
+ sameSite: "Strict" | "Lax" | "None";
22
+ }
23
+
24
+ export interface StorageState {
25
+ cookies: Cookie[];
26
+ origins: { origin: string; localStorage: { name: string; value: string }[] }[];
27
+ }
28
+
29
+ /** Get Chrome profile directory for current OS */
30
+ export function getChromeProfileDir(profile = "Default"): string {
31
+ const home = homedir();
32
+ switch (platform()) {
33
+ case "darwin":
34
+ return join(home, "Library/Application Support/Google/Chrome", profile);
35
+ case "linux":
36
+ return join(home, ".config/google-chrome", profile);
37
+ case "win32":
38
+ return join(
39
+ process.env.LOCALAPPDATA || join(home, "AppData/Local"),
40
+ "Google/Chrome/User Data",
41
+ profile
42
+ );
43
+ default:
44
+ throw new Error(`Unsupported platform: ${platform()}`);
45
+ }
46
+ }
47
+
48
+ /** Get Chrome cookie DB path */
49
+ export function getCookieDbPath(profile = "Default"): string {
50
+ const profileDir = getChromeProfileDir(profile);
51
+ // Windows stores in Network/ subfolder since Chrome 96+
52
+ if (platform() === "win32") {
53
+ const networkPath = join(profileDir, "Network", "Cookies");
54
+ if (existsSync(networkPath)) return networkPath;
55
+ }
56
+ return join(profileDir, "Cookies");
57
+ }
58
+
59
+ /** Get Chrome decryption key for macOS (Keychain) */
60
+ function getMacKey(): Buffer {
61
+ // Allow bypassing keychain entirely via env var (useful for SSH/CI)
62
+ const cachedKey = process.env.CHROME_SAFE_STORAGE_KEY;
63
+ if (cachedKey) {
64
+ return crypto.pbkdf2Sync(cachedKey, "saltysalt", 1003, 16, "sha1");
65
+ }
66
+
67
+ let chromePassword: string;
68
+
69
+ try {
70
+ chromePassword = execSync(
71
+ "security find-generic-password -s 'Chrome Safe Storage' -w",
72
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
73
+ ).trim();
74
+ } catch {
75
+ // Keychain is locked (common over SSH). Try unlocking with password from env.
76
+ const keychainPass = process.env.KEYCHAIN_PASSWORD;
77
+ if (keychainPass) {
78
+ try {
79
+ execSync(
80
+ `security unlock-keychain -p "${keychainPass.replace(/"/g, '\\"')}" ~/Library/Keychains/login.keychain-db`,
81
+ { stdio: "pipe" }
82
+ );
83
+ chromePassword = execSync(
84
+ "security find-generic-password -s 'Chrome Safe Storage' -w",
85
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
86
+ ).trim();
87
+ } catch {
88
+ throw new Error(
89
+ "Cannot access macOS Keychain. The login keychain is locked.\n" +
90
+ "Options:\n" +
91
+ " 1. Run this command in a GUI terminal (not over SSH)\n" +
92
+ " 2. Set CHROME_SAFE_STORAGE_KEY env var (get it once: security find-generic-password -s 'Chrome Safe Storage' -w)\n" +
93
+ " 3. Set KEYCHAIN_PASSWORD env var to your macOS login password\n" +
94
+ " 4. Unlock manually: security unlock-keychain ~/Library/Keychains/login.keychain-db"
95
+ );
96
+ }
97
+ } else {
98
+ throw new Error(
99
+ "Cannot access macOS Keychain. The login keychain is locked.\n" +
100
+ "Options:\n" +
101
+ " 1. Run this command in a GUI terminal (not over SSH)\n" +
102
+ " 2. Set CHROME_SAFE_STORAGE_KEY env var (get it once: security find-generic-password -s 'Chrome Safe Storage' -w)\n" +
103
+ " 3. Set KEYCHAIN_PASSWORD env var to your macOS login password\n" +
104
+ " 4. Unlock manually: security unlock-keychain ~/Library/Keychains/login.keychain-db"
105
+ );
106
+ }
107
+ }
108
+
109
+ return crypto.pbkdf2Sync(chromePassword, "saltysalt", 1003, 16, "sha1");
110
+ }
111
+
112
+ /** Get Chrome decryption key for Linux (libsecret or fallback) */
113
+ function getLinuxKey(): Buffer {
114
+ let password = "peanuts"; // fallback when no keyring
115
+
116
+ try {
117
+ // Try libsecret via secret-tool
118
+ password = execSync(
119
+ "secret-tool lookup application chrome",
120
+ { encoding: "utf-8" }
121
+ ).trim();
122
+ } catch {
123
+ // Fall back to "peanuts" — Chrome's hardcoded fallback on Linux without a keyring
124
+ }
125
+
126
+ return crypto.pbkdf2Sync(password, "saltysalt", 1003, 16, "sha1");
127
+ }
128
+
129
+ /** Get Chrome decryption key for Windows (from Local State) */
130
+ function getWindowsKey(profile = "Default"): Buffer {
131
+ const home = homedir();
132
+ const localStatePath = join(
133
+ process.env.LOCALAPPDATA || join(home, "AppData/Local"),
134
+ "Google/Chrome/User Data/Local State"
135
+ );
136
+
137
+ const localState = JSON.parse(
138
+ require("node:fs").readFileSync(localStatePath, "utf-8")
139
+ );
140
+
141
+ const encryptedKey = Buffer.from(
142
+ localState.os_crypt.encrypted_key,
143
+ "base64"
144
+ );
145
+
146
+ // Remove "DPAPI" prefix (5 bytes)
147
+ const keyWithoutPrefix = encryptedKey.subarray(5);
148
+
149
+ // On Windows, we'd need DPAPI to decrypt — which requires native module
150
+ // For now, throw with guidance
151
+ throw new Error(
152
+ "Windows DPAPI decryption requires the 'win-dpapi' native module. " +
153
+ "Install: npm install win-dpapi"
154
+ );
155
+ }
156
+
157
+ /** Decrypt a single cookie value */
158
+ function decryptCookieValue(
159
+ encryptedValue: Buffer,
160
+ key: Buffer
161
+ ): string {
162
+ if (!encryptedValue || encryptedValue.length === 0) return "";
163
+
164
+ // v10/v11 prefix = encrypted (macOS/Linux use AES-128-CBC)
165
+ const prefix = encryptedValue.subarray(0, 3).toString();
166
+
167
+ if (prefix === "v10" || prefix === "v11") {
168
+ if (platform() === "win32") {
169
+ // Windows v10 uses AES-256-GCM
170
+ const nonce = encryptedValue.subarray(3, 15); // 12-byte nonce
171
+ const ciphertext = encryptedValue.subarray(15, -16);
172
+ const tag = encryptedValue.subarray(-16);
173
+ const decipher = crypto.createDecipheriv("aes-256-gcm", key, nonce);
174
+ decipher.setAuthTag(tag);
175
+ return Buffer.concat([
176
+ decipher.update(ciphertext),
177
+ decipher.final(),
178
+ ]).toString("utf-8");
179
+ }
180
+
181
+ // macOS / Linux: AES-128-CBC
182
+ const iv = Buffer.alloc(16, " "); // 16 spaces
183
+ const ciphertext = encryptedValue.subarray(3);
184
+ const decipher = crypto.createDecipheriv("aes-128-cbc", key, iv);
185
+ decipher.setAutoPadding(true);
186
+ const decrypted = Buffer.concat([
187
+ decipher.update(ciphertext),
188
+ decipher.final(),
189
+ ]);
190
+ return decrypted.toString("utf-8");
191
+ }
192
+
193
+ // Not encrypted — return as-is
194
+ return encryptedValue.toString("utf-8");
195
+ }
196
+
197
+ /** Map Chrome sameSite int to Playwright string */
198
+ function mapSameSite(value: number): "Strict" | "Lax" | "None" {
199
+ switch (value) {
200
+ case 2:
201
+ return "Strict";
202
+ case 1:
203
+ return "Lax";
204
+ default:
205
+ return "None";
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Extract cookies from local Chrome for specific domains (or all cookies if domains is empty).
211
+ *
212
+ * @param domains - Array of domains to extract (e.g., [".google.com"]). Empty = all cookies.
213
+ * @param profile - Chrome profile name (default: "Default")
214
+ */
215
+ export async function extractCookies(
216
+ domains: string[],
217
+ profile = "Default"
218
+ ): Promise<Cookie[]> {
219
+ const dbPath = getCookieDbPath(profile);
220
+
221
+ if (!existsSync(dbPath)) {
222
+ throw new Error(
223
+ `Chrome cookie database not found at: ${dbPath}\n` +
224
+ `Make sure Chrome is installed and the profile "${profile}" exists.`
225
+ );
226
+ }
227
+
228
+ // Copy DB to temp file to avoid lock conflicts with running Chrome
229
+ const tempDir = mkdtempSync(join(tmpdir(), "browser-sync-"));
230
+ const tempDb = join(tempDir, "Cookies");
231
+ copyFileSync(dbPath, tempDb);
232
+
233
+ // Also copy WAL/SHM if they exist (needed for recent writes)
234
+ for (const ext of ["-wal", "-shm"]) {
235
+ if (existsSync(dbPath + ext)) {
236
+ copyFileSync(dbPath + ext, tempDb + ext);
237
+ }
238
+ }
239
+
240
+ // Get decryption key
241
+ let key: Buffer;
242
+ switch (platform()) {
243
+ case "darwin":
244
+ key = getMacKey();
245
+ break;
246
+ case "linux":
247
+ key = getLinuxKey();
248
+ break;
249
+ case "win32":
250
+ key = getWindowsKey(profile);
251
+ break;
252
+ default:
253
+ throw new Error(`Unsupported platform: ${platform()}`);
254
+ }
255
+
256
+ // Query cookies — dynamic import for better-sqlite3
257
+ const Database = (await import("better-sqlite3")).default;
258
+ const db = new Database(tempDb, { readonly: true });
259
+
260
+ let query = `SELECT name, encrypted_value, host_key, path, expires_utc, is_httponly, is_secure, samesite FROM cookies`;
261
+
262
+ if (domains.length > 0) {
263
+ const domainClauses = domains
264
+ .map((d) => `host_key LIKE '%${d.replace(/'/g, "''")}'`)
265
+ .join(" OR ");
266
+ query += ` WHERE ${domainClauses}`;
267
+ }
268
+
269
+ const rows = db
270
+ .prepare(query)
271
+ .all() as Array<{
272
+ name: string;
273
+ encrypted_value: Buffer;
274
+ host_key: string;
275
+ path: string;
276
+ expires_utc: number;
277
+ is_httponly: number;
278
+ is_secure: number;
279
+ samesite: number;
280
+ }>;
281
+
282
+ db.close();
283
+
284
+ const cookies: Cookie[] = [];
285
+ for (const row of rows) {
286
+ try {
287
+ const value = decryptCookieValue(row.encrypted_value, key);
288
+ if (!value) continue;
289
+
290
+ cookies.push({
291
+ name: row.name,
292
+ value,
293
+ domain: row.host_key,
294
+ path: row.path,
295
+ // Chrome stores expires as microseconds since 1601-01-01; convert to Unix seconds
296
+ expires:
297
+ row.expires_utc === 0
298
+ ? -1
299
+ : Math.floor((row.expires_utc / 1000000) - 11644473600),
300
+ httpOnly: row.is_httponly === 1,
301
+ secure: row.is_secure === 1,
302
+ sameSite: mapSameSite(row.samesite),
303
+ });
304
+ } catch {
305
+ // Skip cookies that fail to decrypt (stale entries, etc.)
306
+ }
307
+ }
308
+
309
+ return cookies;
310
+ }
311
+
312
+ /**
313
+ * Extract cookies and format as Playwright-compatible StorageState
314
+ */
315
+ export async function extractStorageState(
316
+ domains: string[],
317
+ profile = "Default"
318
+ ): Promise<StorageState> {
319
+ const cookies = await extractCookies(domains, profile);
320
+ return { cookies, origins: [] };
321
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { extractCookies, extractStorageState, getChromeProfileDir, getCookieDbPath } from "./extract.js";
2
+ export type { Cookie, StorageState } from "./extract.js";
3
+ export { loginViaBrowser, loginWithToken, pushCookies, loadAuth, saveAuth, clearAuth } from "./api.js";
package/src/server.ts ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Server-side handler for browser-sync cookie injection.
3
+ *
4
+ * Receives cookies from the CLI, connects to the tenant's Chrome via CDP,
5
+ * and injects them using Network.setCookies.
6
+ *
7
+ * Mount at: POST /admin/api/browser-sync/cookies
8
+ */
9
+
10
+ import type { IncomingMessage, ServerResponse } from "node:http";
11
+ import type { StorageState } from "./extract.js";
12
+
13
+ interface BrowserSyncRequest {
14
+ storageState: StorageState;
15
+ subdomain?: string;
16
+ }
17
+
18
+ /**
19
+ * Inject cookies into a tenant's cloud Chrome via CDP.
20
+ *
21
+ * @param cdpUrl - The Chrome DevTools Protocol WebSocket URL (e.g., ws://localhost:3000)
22
+ * @param cookies - Playwright-format cookies to inject
23
+ */
24
+ async function injectCookiesViaCDP(
25
+ cdpUrl: string,
26
+ cookies: StorageState["cookies"]
27
+ ): Promise<{ injected: number; errors: string[] }> {
28
+ // Connect to Chrome via CDP WebSocket
29
+ const ws = await connectCDP(cdpUrl);
30
+ const errors: string[] = [];
31
+ let injected = 0;
32
+
33
+ try {
34
+ // Convert to CDP Network.CookieParam format
35
+ const cdpCookies = cookies.map((c) => ({
36
+ name: c.name,
37
+ value: c.value,
38
+ domain: c.domain,
39
+ path: c.path,
40
+ expires: c.expires > 0 ? c.expires : undefined,
41
+ httpOnly: c.httpOnly,
42
+ secure: c.secure,
43
+ sameSite: c.sameSite,
44
+ }));
45
+
46
+ // Inject via Network.setCookies (batch)
47
+ const result = await sendCDPCommand(ws, "Network.setCookies", {
48
+ cookies: cdpCookies,
49
+ });
50
+
51
+ if (result.error) {
52
+ errors.push(result.error.message);
53
+ } else {
54
+ injected = cdpCookies.length;
55
+ }
56
+ } finally {
57
+ ws.close();
58
+ }
59
+
60
+ return { injected, errors };
61
+ }
62
+
63
+ /** Connect to Chrome CDP endpoint */
64
+ async function connectCDP(cdpUrl: string): Promise<WebSocket> {
65
+ // Get the debugger WebSocket URL from /json/version
66
+ const httpUrl = cdpUrl.replace("ws://", "http://").replace("wss://", "https://");
67
+ const versionRes = await fetch(`${httpUrl}/json/version`);
68
+ const version = (await versionRes.json()) as { webSocketDebuggerUrl: string };
69
+
70
+ return new Promise((resolve, reject) => {
71
+ const ws = new WebSocket(version.webSocketDebuggerUrl);
72
+ ws.onopen = () => resolve(ws);
73
+ ws.onerror = (err) => reject(new Error(`CDP connection failed: ${err}`));
74
+ });
75
+ }
76
+
77
+ /** Send a CDP command and wait for response */
78
+ function sendCDPCommand(
79
+ ws: WebSocket,
80
+ method: string,
81
+ params: Record<string, unknown>
82
+ ): Promise<{ error?: { message: string }; result?: unknown }> {
83
+ const id = Math.floor(Math.random() * 1e9);
84
+
85
+ return new Promise((resolve) => {
86
+ const handler = (event: MessageEvent) => {
87
+ const data = JSON.parse(String(event.data));
88
+ if (data.id === id) {
89
+ ws.removeEventListener("message", handler);
90
+ resolve(data);
91
+ }
92
+ };
93
+ ws.addEventListener("message", handler);
94
+ ws.send(JSON.stringify({ id, method, params }));
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Express/Hono-style handler for the browser-sync cookie push endpoint.
100
+ * Integrate with the bot's admin API router.
101
+ */
102
+ export async function handleBrowserSyncPush(
103
+ body: BrowserSyncRequest,
104
+ resolveCdpUrl: (subdomain?: string) => Promise<string>
105
+ ): Promise<{ injected: number; errors: string[] }> {
106
+ const { storageState, subdomain } = body;
107
+
108
+ if (!storageState?.cookies?.length) {
109
+ return { injected: 0, errors: ["No cookies provided"] };
110
+ }
111
+
112
+ const cdpUrl = await resolveCdpUrl(subdomain);
113
+ return injectCookiesViaCDP(cdpUrl, storageState.cookies);
114
+ }
115
+
116
+ export type { BrowserSyncRequest };
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": true,
12
+ "sourceMap": true
13
+ },
14
+ "include": ["src/**/*"]
15
+ }