diff --git a/README.md b/README.md index 7fb9c07..119b81d 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,10 @@ agent-slack auth test ``` > [!NOTE] -> `import-brave` / `import-chrome` read tokens from a logged-in Slack tab via AppleScript. Both browsers ship with **Allow JavaScript from Apple Events** disabled by default — enable it in **View → Developer** before running these commands. macOS will prompt for your password the first time. +> `import-brave` / `import-chrome` read tokens from a logged-in Slack tab via AppleScript. Both browsers ship with **Allow JavaScript from Apple Events** disabled by default — enable it in **View → Developer** before running these commands. macOS will prompt for your password the first time. The first run also needs Automation consent for your terminal (**System Settings → Privacy & Security → Automation**) and access to the browser's `Safe Storage` keychain entry, which holds the key for the HttpOnly `d` cookie. + +> [!NOTE] +> Tokens are stored per workspace URL. On Enterprise Grid, import from a tab on the **workspace** URL (`https://.slack.com`) rather than the org shell (`https://.enterprise.slack.com`) so the stored entry matches the workspace you query. `agent-slack auth whoami` lists what each entry is keyed to. Alternatively, set env vars: diff --git a/src/auth/chrome.ts b/src/auth/chrome.ts index a31e9d6..2264d3a 100644 --- a/src/auth/chrome.ts +++ b/src/auth/chrome.ts @@ -1,5 +1,12 @@ -import { execSync } from "node:child_process"; -import { platform } from "node:os"; +import { execFileSync } from "node:child_process"; +import { copyFileSync, existsSync, readdirSync, unlinkSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { homedir, platform, tmpdir } from "node:os"; +import { join } from "node:path"; +import { queryReadonlySqlite } from "./firefox-profile.ts"; +import { decryptChromiumCookieValue } from "./chromium-cookie.ts"; +import { getKeychainTimeoutMs } from "./keychain.ts"; +import { isRecord } from "../lib/object-type-guards.ts"; type ChromeExtractedTeam = { url: string; name?: string; token: string }; @@ -10,32 +17,86 @@ export type ChromeExtracted = { const IS_MACOS = platform() === "darwin"; -function escapeOsaScript(script: string): string { - // osascript -e '...' - return script.replace(/'/g, `'"'"'`); +// Chrome ships with `Allow JavaScript from Apple Events` OFF. Without it, +// `execute javascript` from osascript fails, and that is how this module reads +// `localStorage.localConfig_v2` out of a Slack tab. +export class ChromeAppleScriptDisabledError extends Error { + constructor() { + super( + "Chrome is blocking JavaScript from Apple Events.\n" + + "Enable it: Chrome menu → View → Developer → Allow JavaScript from Apple Events\n" + + "Then re-run: agent-slack auth import-chrome", + ); + this.name = "ChromeAppleScriptDisabledError"; + } } -function osascript(script: string): string { - return execSync(`osascript -e '${escapeOsaScript(script)}'`, { - encoding: "utf8", - timeout: 7000, - stdio: ["ignore", "pipe", "pipe"], - }).trim(); +// macOS gates Apple events behind per-app Automation consent. A denied or +// never-prompted terminal gets -1743 with no visible prompt. +export class ChromeAutomationNotAuthorizedError extends Error { + constructor() { + super( + "This terminal is not authorized to send Apple events to Google Chrome.\n" + + "Allow it: System Settings → Privacy & Security → Automation → → Google Chrome\n" + + "Then re-run: agent-slack auth import-chrome", + ); + this.name = "ChromeAutomationNotAuthorizedError"; + } } -function cookieScript(): string { - return ` - tell application "Google Chrome" - repeat with w in windows - repeat with t in tabs of w - if URL of t contains "slack.com" then - return execute t javascript "document.cookie.split('; ').find(c => c.startsWith('d='))?.split('=')[1] || ''" - end if - end repeat - end repeat - return "" - end tell - `; +export class ChromeNoSlackTabError extends Error { + constructor() { + super( + "No Slack tab found in Google Chrome.\n" + + "Open your workspace in Chrome (for Enterprise Grid use the workspace URL, " + + "e.g. https://.slack.com, not the https://.enterprise.slack.com shell), " + + "sign in, then re-run: agent-slack auth import-chrome", + ); + this.name = "ChromeNoSlackTabError"; + } +} + +// Chrome 127+ on macOS/Windows can wrap cookie values with App-Bound +// Encryption (`v20`), which the Safe Storage keychain password cannot open. +export class ChromeAppBoundEncryptionError extends Error { + constructor() { + super( + "Chrome encrypted the Slack cookie with App-Bound Encryption (v20), which cannot be decrypted outside Chrome.\n" + + "Use another import path instead: agent-slack auth import-desktop (Slack app), " + + "auth import-brave, or auth import-firefox.", + ); + this.name = "ChromeAppBoundEncryptionError"; + } +} + +// --- AppleScript helpers (for extracting teams from Chrome tabs) --- + +const APPLESCRIPT_JS_DISABLED_MARKER = "Executing JavaScript through AppleScript is turned off"; +const APPLESCRIPT_NOT_AUTHORIZED_MARKER = "-1743"; +const NO_SLACK_TAB_SENTINEL = "__AGENT_SLACK_NO_TAB__"; + +function osascript(script: string): string { + try { + return execFileSync("osascript", ["-e", script], { + encoding: "utf8", + timeout: 7000, + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } catch (err: unknown) { + const stderr = + err && typeof err === "object" && "stderr" in err + ? String((err as { stderr: unknown }).stderr ?? "") + : ""; + const message = err instanceof Error ? err.message : String(err); + const combined = `${stderr}\n${message}`; + if (combined.includes(APPLESCRIPT_JS_DISABLED_MARKER)) { + throw new ChromeAppleScriptDisabledError(); + } + if (combined.includes(APPLESCRIPT_NOT_AUTHORIZED_MARKER)) { + throw new ChromeAutomationNotAuthorizedError(); + } + throw err; + } } const TEAM_JSON_PATHS = [ @@ -46,23 +107,6 @@ const TEAM_JSON_PATHS = [ "JSON.stringify(window.boot_data?.teams || {})", ]; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function toChromeTeam(value: unknown): ChromeExtractedTeam | null { - if (!isRecord(value)) { - return null; - } - const token = typeof value.token === "string" ? value.token : null; - const url = typeof value.url === "string" ? value.url : null; - if (!token || !url || !token.startsWith("xoxc-")) { - return null; - } - const name = typeof value.name === "string" ? value.name : undefined; - return { url, name, token }; -} - function teamsScript(): string { const tryPaths = TEAM_JSON_PATHS.map( (expr) => `try { var v = ${expr}; if (v && v !== '{}' && v !== 'null') return v; } catch(e) {}`, @@ -76,39 +120,242 @@ function teamsScript(): string { end if end repeat end repeat - return "{}" + return "${NO_SLACK_TAB_SENTINEL}" end tell `; } -export function extractFromChrome(): ChromeExtracted | null { - if (!IS_MACOS) { +function toChromeTeam(value: unknown): ChromeExtractedTeam | null { + if (!isRecord(value)) { + return null; + } + const token = typeof value.token === "string" ? value.token : null; + const url = typeof value.url === "string" ? value.url : null; + if (!token || !url || !token.startsWith("xoxc-")) { return null; } + const name = typeof value.name === "string" ? value.name : undefined; + return { url, name, token }; +} + +export function parseChromeTeams(teamsRaw: string): ChromeExtractedTeam[] { + if (teamsRaw.includes(NO_SLACK_TAB_SENTINEL)) { + throw new ChromeNoSlackTabError(); + } + + let teamsObj: unknown = {}; try { - const cookie = osascript(cookieScript()); - if (!cookie || !cookie.startsWith("xoxd-")) { - return null; + teamsObj = JSON.parse(teamsRaw || "{}"); + } catch { + teamsObj = {}; + } + + const teamsRecord = isRecord(teamsObj) ? teamsObj : {}; + return Object.values(teamsRecord) + .map((t) => toChromeTeam(t)) + .filter((t): t is ChromeExtractedTeam => t !== null); +} + +function extractTeamsFromChromeTab(): ChromeExtractedTeam[] { + return parseChromeTeams(osascript(teamsScript())); +} + +// --- Cookie extraction from Chrome's SQLite database --- +// +// The Slack `d` cookie is HttpOnly, so `document.cookie` in the page context +// never sees it. It has to come from Chrome's cookie store, decrypted with the +// `Chrome Safe Storage` keychain password. + +const CHROME_USER_DATA_DIR = join(homedir(), "Library", "Application Support", "Google", "Chrome"); + +export function getChromeProfileDirs(userDataDir: string): string[] { + const profiles: string[] = []; + if (existsSync(join(userDataDir, "Default"))) { + profiles.push("Default"); + } + try { + for (const entry of readdirSync(userDataDir).sort()) { + if (/^Profile \d+$/.test(entry)) { + profiles.push(entry); + } } + } catch { + // user data dir may not exist + } + return profiles; +} - const teamsRaw = osascript(teamsScript()); - let teamsObj: unknown = {}; +export function getChromeCookieDbPaths(userDataDir: string): string[] { + const paths: string[] = []; + for (const profile of getChromeProfileDirs(userDataDir)) { + for (const candidate of [ + join(userDataDir, profile, "Network", "Cookies"), + join(userDataDir, profile, "Cookies"), + ]) { + if (existsSync(candidate)) { + paths.push(candidate); + } + } + } + return paths; +} + +function getChromeSafeStoragePasswords(): string[] { + const services = ["Chrome Safe Storage", "Chromium Safe Storage"]; + const passwords: string[] = []; + for (const service of services) { try { - teamsObj = JSON.parse(teamsRaw || "{}"); + const out = execFileSync("security", ["find-generic-password", "-w", "-s", service], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: getKeychainTimeoutMs(), + }).trim(); + if (out) { + passwords.push(out); + } } catch { - teamsObj = {}; + // continue } + } + return [...new Set(passwords)]; +} - const teamsRecord = isRecord(teamsObj) ? teamsObj : {}; - const teams: ChromeExtractedTeam[] = Object.values(teamsRecord) - .map((t) => toChromeTeam(t)) - .filter((t): t is ChromeExtractedTeam => t !== null); +export type ChromeCookieRow = { + host_key: string; + name: string; + value: string; + encrypted_value: Uint8Array; +}; + +/** + * Decrypt one Chrome cookie row into an `xoxd-*` token. + * + * Exported for tests; throws `ChromeAppBoundEncryptionError` on `v20` values. + */ +export function decryptSlackCookieRow(row: ChromeCookieRow, passwords: string[]): string | null { + if (row.value && row.value.startsWith("xoxd-")) { + return row.value; + } + + const encrypted = Buffer.from(row.encrypted_value || []); + if (encrypted.length === 0) { + return null; + } + + const prefix = encrypted.subarray(0, 3).toString("utf8"); + if (prefix === "v20") { + throw new ChromeAppBoundEncryptionError(); + } - if (teams.length === 0) { - return null; + const data = prefix === "v10" || prefix === "v11" ? encrypted.subarray(3) : encrypted; + for (const password of passwords) { + try { + const decrypted = decryptChromiumCookieValue(data, { password, iterations: 1003 }); + const match = decrypted.match(/xoxd-[A-Za-z0-9%/+_=.-]+/); + if (match) { + return match[0]!; + } + } catch { + // continue } - return { cookie_d: cookie, teams }; - } catch { + } + return null; +} + +/** + * Read Chrome's cookie DB through a copy. + * + * Chrome keeps the live DB open, so a readonly handle on it can fail or miss + * writes still sitting in the write-ahead log. Copying `Cookies` plus its + * `-wal`/`-shm` siblings gives a consistent, lock-free snapshot. + */ +async function querySlackCookieRows(dbPath: string): Promise { + const snapshot = join(tmpdir(), `agent-slack-chrome-cookies-${randomUUID()}`); + const sidecars = ["-wal", "-shm"]; + copyFileSync(dbPath, snapshot); + for (const suffix of sidecars) { + if (existsSync(`${dbPath}${suffix}`)) { + try { + copyFileSync(`${dbPath}${suffix}`, `${snapshot}${suffix}`); + } catch { + // sidecar is best effort + } + } + } + + try { + return (await queryReadonlySqlite( + snapshot, + "select host_key, name, value, encrypted_value from cookies where name = 'd' and host_key like '%slack.com' order by length(encrypted_value) desc", + )) as ChromeCookieRow[]; + } finally { + for (const path of [snapshot, ...sidecars.map((suffix) => `${snapshot}${suffix}`)]) { + try { + unlinkSync(path); + } catch { + // ignore + } + } + } +} + +async function extractCookieDFromChrome(): Promise { + const dbPaths = getChromeCookieDbPaths(CHROME_USER_DATA_DIR); + if (dbPaths.length === 0) { + throw new Error(`Chrome Cookies DB not found under ${CHROME_USER_DATA_DIR}`); + } + + const passwords = getChromeSafeStoragePasswords(); + const errors: string[] = []; + + for (const dbPath of dbPaths) { + let rows: ChromeCookieRow[]; + try { + rows = await querySlackCookieRows(dbPath); + } catch (err: unknown) { + errors.push(`${dbPath}: ${err instanceof Error ? err.message : String(err)}`); + continue; + } + + if (!rows || rows.length === 0) { + errors.push(`${dbPath}: no Slack 'd' cookie`); + continue; + } + + for (const row of rows) { + // App-Bound Encryption is a dead end for every profile, so let it escape. + const cookie = decryptSlackCookieRow(row, passwords); + if (cookie) { + return cookie; + } + } + errors.push( + passwords.length === 0 + ? `${dbPath}: no Chrome Safe Storage password available from the keychain` + : `${dbPath}: could not decrypt Slack 'd' cookie`, + ); + } + + throw new Error(`Could not read the Slack 'd' cookie from Chrome:\n - ${errors.join("\n - ")}`); +} + +// --- Main export --- + +export async function extractFromChrome(): Promise { + if (!IS_MACOS) { return null; } + + // Actionable failures propagate; only an unrecognized shape returns null. + const teams = extractTeamsFromChromeTab(); + if (teams.length === 0) { + return null; + } + + const cookie_d = await extractCookieDFromChrome(); + if (!cookie_d || !cookie_d.startsWith("xoxd-")) { + return null; + } + + return { cookie_d, teams }; } diff --git a/src/cli/auth-command.ts b/src/cli/auth-command.ts index f4f3127..87df7ae 100644 --- a/src/cli/auth-command.ts +++ b/src/cli/auth-command.ts @@ -78,16 +78,18 @@ export function registerAuthCommand(input: { program: Command; ctx: CliContext } .description("Import xoxc/xoxd from a logged-in Slack tab in Google Chrome (macOS)") .action(async () => { try { - const extracted = input.ctx.importChrome(); + const extracted = await input.ctx.importChrome(); if (!extracted) { throw new Error( "Could not extract tokens from Chrome. Open Slack in Chrome and ensure you're logged in.", ); } + const importedUrls: string[] = []; for (const team of extracted.teams) { + const workspaceUrl = input.ctx.normalizeUrl(team.url); await upsertWorkspace({ - workspace_url: input.ctx.normalizeUrl(team.url), + workspace_url: workspaceUrl, workspace_name: team.name, auth: { auth_type: "browser", @@ -95,8 +97,13 @@ export function registerAuthCommand(input: { program: Command; ctx: CliContext } xoxd_cookie: extracted.cookie_d, }, }); + importedUrls.push(workspaceUrl); } - console.log(`Imported ${extracted.teams.length} workspace token(s) from Chrome.`); + // Print the URLs: tokens are keyed by workspace URL, and on Enterprise + // Grid an enterprise-scoped entry cannot serve workspace endpoints. + console.log( + `Imported ${extracted.teams.length} workspace token(s) from Chrome:\n - ${importedUrls.join("\n - ")}`, + ); } catch (err: unknown) { console.error(input.ctx.errorMessage(err)); process.exitCode = 1; diff --git a/src/cli/context-client-resolver.ts b/src/cli/context-client-resolver.ts index d9beb8b..5351fcf 100644 --- a/src/cli/context-client-resolver.ts +++ b/src/cli/context-client-resolver.ts @@ -140,9 +140,15 @@ export async function getClientForWorkspace(workspaceUrl?: string): Promise<{ cookie_d: string; teams: { url: string; name?: string; token: string }[]; }[] = []; - const chromeResult = extractFromChrome(); - if (chromeResult && chromeResult.teams.length > 0) { - browserSources.push(chromeResult); + try { + const chromeResult = await extractFromChrome(); + if (chromeResult && chromeResult.teams.length > 0) { + browserSources.push(chromeResult); + } + } catch { + // Chrome is an opportunistic fallback here: its setup errors (no Slack tab, + // Apple Events disabled, App-Bound Encryption) are actionable only for the + // explicit `auth import-chrome` command, not for every read command. } try { const braveResult = await extractFromBrave(); diff --git a/test/canvas-create.test.ts b/test/canvas-create.test.ts index e904eb2..f669f0f 100644 --- a/test/canvas-create.test.ts +++ b/test/canvas-create.test.ts @@ -63,7 +63,7 @@ function createContext( teams: [], source: { leveldb_path: "", cookies_path: "" }, }), - importChrome: () => ({ cookie_d: "", teams: [] }), + importChrome: async () => ({ cookie_d: "", teams: [] }), importBrave: async () => null, importFirefox: async () => null, }; diff --git a/test/channel-command.test.ts b/test/channel-command.test.ts index 2759edc..6984cef 100644 --- a/test/channel-command.test.ts +++ b/test/channel-command.test.ts @@ -55,7 +55,7 @@ function createContext() { teams: [], source: { leveldb_path: "", cookies_path: "" }, }), - importChrome: () => ({ cookie_d: "", teams: [] }), + importChrome: async () => ({ cookie_d: "", teams: [] }), importBrave: async () => null, importFirefox: async () => null, }; diff --git a/test/chrome-auth.test.ts b/test/chrome-auth.test.ts new file mode 100644 index 0000000..d0000ff --- /dev/null +++ b/test/chrome-auth.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; +import { createCipheriv, pbkdf2Sync } from "node:crypto"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + ChromeAppBoundEncryptionError, + ChromeNoSlackTabError, + decryptSlackCookieRow, + getChromeCookieDbPaths, + getChromeProfileDirs, + parseChromeTeams, + type ChromeCookieRow, +} from "../src/auth/chrome.ts"; + +const PASSWORD = "chrome-safe-storage-password"; + +function encryptChromiumCookie(plaintext: string, prefix: string): Uint8Array { + const key = pbkdf2Sync(PASSWORD, Buffer.from("saltysalt", "utf8"), 1003, 16, "sha1"); + const cipher = createCipheriv("aes-128-cbc", key, Buffer.alloc(16, " ")); + const body = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + return Buffer.concat([Buffer.from(prefix, "utf8"), body]); +} + +function makeRow(overrides: Partial): ChromeCookieRow { + return { + host_key: ".slack.com", + name: "d", + value: "", + encrypted_value: new Uint8Array(), + ...overrides, + }; +} + +describe("parseChromeTeams", () => { + test("throws an actionable error when no Slack tab is open", () => { + expect(() => parseChromeTeams("__AGENT_SLACK_NO_TAB__")).toThrow(ChromeNoSlackTabError); + }); + + test("keeps only xoxc entries that carry a workspace URL", () => { + const raw = JSON.stringify({ + TS7LN8J6M: { url: "https://gpcorporate.slack.com/", name: "GP", token: "xoxc-workspace" }, + E04N67U6VNC: { url: "https://gp.enterprise.slack.com/", token: "xoxc-enterprise" }, + bogus: { url: "https://other.slack.com/", token: "xoxb-not-a-client-token" }, + noUrl: { token: "xoxc-orphan" }, + }); + + expect(parseChromeTeams(raw)).toEqual([ + { url: "https://gpcorporate.slack.com/", name: "GP", token: "xoxc-workspace" }, + { url: "https://gp.enterprise.slack.com/", name: undefined, token: "xoxc-enterprise" }, + ]); + }); + + test("returns no teams for unusable payloads", () => { + expect(parseChromeTeams("{}")).toEqual([]); + expect(parseChromeTeams("not json")).toEqual([]); + }); +}); + +describe("decryptSlackCookieRow", () => { + test("prefers an already plaintext value", () => { + const row = makeRow({ value: "xoxd-plaintext" }); + expect(decryptSlackCookieRow(row, [])).toBe("xoxd-plaintext"); + }); + + test("decrypts a v10 Safe Storage value", () => { + const row = makeRow({ encrypted_value: encryptChromiumCookie("xoxd-secret-token", "v10") }); + expect(decryptSlackCookieRow(row, ["wrong-password", PASSWORD])).toBe("xoxd-secret-token"); + }); + + test("percent-decodes the stored cookie", () => { + const row = makeRow({ encrypted_value: encryptChromiumCookie("xoxd-a%2Fb", "v10") }); + expect(decryptSlackCookieRow(row, [PASSWORD])).toBe("xoxd-a/b"); + }); + + test("reports App-Bound Encryption instead of failing silently", () => { + const row = makeRow({ encrypted_value: encryptChromiumCookie("xoxd-secret-token", "v20") }); + expect(() => decryptSlackCookieRow(row, [PASSWORD])).toThrow(ChromeAppBoundEncryptionError); + }); + + test("returns null when no password opens the value", () => { + const row = makeRow({ encrypted_value: encryptChromiumCookie("xoxd-secret-token", "v10") }); + expect(decryptSlackCookieRow(row, ["wrong-password"])).toBeNull(); + }); + + test("returns null for an empty encrypted value", () => { + expect(decryptSlackCookieRow(makeRow({}), [PASSWORD])).toBeNull(); + }); +}); + +describe("Chrome profile discovery", () => { + test("finds Default plus numbered profiles and both cookie locations", () => { + const userDataDir = mkdtempSync(join(tmpdir(), "agent-slack-chrome-test-")); + mkdirSync(join(userDataDir, "Default", "Network"), { recursive: true }); + mkdirSync(join(userDataDir, "Profile 1"), { recursive: true }); + mkdirSync(join(userDataDir, "Crashpad"), { recursive: true }); + writeFileSync(join(userDataDir, "Default", "Cookies"), ""); + writeFileSync(join(userDataDir, "Default", "Network", "Cookies"), ""); + writeFileSync(join(userDataDir, "Profile 1", "Cookies"), ""); + + expect(getChromeProfileDirs(userDataDir)).toEqual(["Default", "Profile 1"]); + expect(getChromeCookieDbPaths(userDataDir)).toEqual([ + join(userDataDir, "Default", "Network", "Cookies"), + join(userDataDir, "Default", "Cookies"), + join(userDataDir, "Profile 1", "Cookies"), + ]); + }); + + test("returns nothing for a missing user data dir", () => { + const missing = join(tmpdir(), "agent-slack-chrome-absent-dir"); + expect(getChromeProfileDirs(missing)).toEqual([]); + expect(getChromeCookieDbPaths(missing)).toEqual([]); + }); +}); diff --git a/test/message-draft-actions.test.ts b/test/message-draft-actions.test.ts index c636a56..cf2b35d 100644 --- a/test/message-draft-actions.test.ts +++ b/test/message-draft-actions.test.ts @@ -122,7 +122,7 @@ function createContext( teams: [], source: { leveldb_path: "", cookies_path: "" }, }), - importChrome: () => ({ cookie_d: "", teams: [] }), + importChrome: async () => ({ cookie_d: "", teams: [] }), importBrave: async () => null, importFirefox: async () => null, } satisfies CliContext; diff --git a/test/message-send.test.ts b/test/message-send.test.ts index 12ff5e3..ab1df0f 100644 --- a/test/message-send.test.ts +++ b/test/message-send.test.ts @@ -95,7 +95,7 @@ function createContext( teams: [], source: { leveldb_path: "", cookies_path: "" }, }), - importChrome: () => ({ cookie_d: "", teams: [] }), + importChrome: async () => ({ cookie_d: "", teams: [] }), importBrave: async () => null, importFirefox: async () => null, } satisfies CliContext; diff --git a/test/search-command.test.ts b/test/search-command.test.ts index f918ce9..074e31a 100644 --- a/test/search-command.test.ts +++ b/test/search-command.test.ts @@ -100,7 +100,7 @@ function createContext(calls: ApiCall[]): CliContext { teams: [], source: { leveldb_path: "", cookies_path: "" }, }), - importChrome: () => ({ cookie_d: "", teams: [] }), + importChrome: async () => ({ cookie_d: "", teams: [] }), importBrave: async () => null, importFirefox: async () => null, };