storebound/cli/console/session.ts
CRT 0035931734 console fill: never copy Google session cookies by default (device-bound); own profile in a Chrome started without automation switches
Google binds its session cookies to the device (Device Bound Session Credentials) and signs out
sessions whose cookies look stolen, which can sign the owner out of their real Chrome. The default is
now storebound's own profile per store; storebound starts Chrome itself (--user-data-dir,
--remote-debugging-port=0, no --enable-automation, navigator.webdriver false) and attaches over CDP.
--use-chrome-session google|apple is an explicit opt-in that warns. /signin/rejected is said plainly.
2026-10-12 07:46:38 +08:00

381 lines
26 KiB
TypeScript

// The browser `storebound console fill` works in (the log says which):
// (default) storebound's own persistent profile, one per store (~/.storebound/console-profile/<store>, or --profile /
// STOREBOUND_CONSOLE_PROFILE): the owner signs in there once, by hand (storebound never types passwords), and the
// sign-in is kept between runs. Nothing of the owner's own Chrome is read or copied.
// (a) attach: Chrome already runs with remote debugging (a DevToolsActivePort file in its user data dir, or
// --cdp http://127.0.0.1:<port>): connectOverCDP and one new tab in that session; storebound closes only its tab.
// The owner's own browser, in place: no cookie leaves it.
// (b) copy, OPT-IN ONLY (`--use-chrome-session google|apple`, macOS): a temporary profile with ONLY `Local State` and
// the profile's `Cookies` (+ `Cookies-journal`), filtered down to the cookies of the ONE store the form is for
// (apple: apple.com hosts; google: play.google.com / accounts.google.com), opened in real Google Chrome. Never
// copied: passwords (Login Data), history, extensions, anything else. Deleted when the run ends.
// Why the copy is opt-in (an incident, October 2026): a copied Google session reached Play Console once, then Google
// answered /v3/signin/rejected and the account chooser. Google binds its session cookies to the device: Chrome's Device
// Bound Session Credentials keep a non-exportable key in the TPM / Secure Enclave and Google only renews the
// short-lived cookies for the browser holding it (public on Windows in Chrome 146, rolling out on macOS from Chrome
// 147), and Google signs out a session whose cookie looks stolen: "the user is logged out of their account for that
// session and any related suspicious sessions on that device" and must pass an extra verification to sign back in.
// A copy of the owner's Google cookies used by a second Chrome is exactly what that detection looks for, so it can
// sign the owner out of their REAL Chrome or flag the account. Apple publishes no device binding or cookie-theft
// sign-out for Apple ID web sessions (fastlane's spaceauth reuses them on other machines; Apple ties them to a region),
// but it does not rule one out either, so the Apple copy is opt-in as well.
// The window: storebound starts Chrome ITSELF (`--user-data-dir=<its profile> --remote-debugging-port=0`; allowed on a
// non-default user data dir) and attaches over CDP, instead of Playwright's launch: no `--enable-automation` (no
// "controlled by automated test software" bar), `--disable-blink-features=AutomationControlled` (navigator.webdriver
// is false), none of Playwright's other switches (mock keychain, disabled sync / component updates ...), the browser's
// own user agent. Google decides by itself whether it accepts a sign-in there; when it answers /signin/rejected,
// storebound says so (googleRejected).
// Keeping the sign-in: App Store Connect's and Google's sign-in cookies are partly SESSION cookies (no expiry).
// Chromium writes them to its Cookies file but deletes them at the next start unless that start restores the last
// session, so storebound launches with --restore-last-session (the tabs it brings back are closed again; storebound
// works in one new tab) and always closes the browser gracefully (CDP Browser.close, window closed, Ctrl+C), never
// kills it, so Chromium flushes its cookie store. Apple keeps a long sign-in only with "Keep me signed in" / "Trust
// this browser" ticked (storebound says so when it sees the sign-in page).
// Cookies are never printed, logged or sent anywhere: only file names and counts are reported.
// Sources (checked October 2026): DBSC https://blog.google/security/protecting-cookies-with-device-bound-session-credentials/
// and https://developer.chrome.com/docs/web-platform/device-bound-session-credentials ; Google's sign-out on suspicious
// session cookies https://knowledge.workspace.google.com/admin/security/investigate-and-take-action-on-suspicious-session-cookies ;
// Apple sessions on other machines https://docs.fastlane.tools/getting-started/ios/authentication/ ; Chrome user data
// dir and DevToolsActivePort https://chromium.googlesource.com/chromium/src/+/main/docs/user_data_dir.md ; Chrome 136
// remote debugging change https://developer.chrome.com/blog/remote-debugging-port ; Playwright connectOverCDP
// https://playwright.dev/docs/api/class-browsertype#browser-type-connect-over-cdp
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { chromiumFor, installedBrowsers, type LaunchDeps, persistentExecutable, profileHolder } from '../shots/browser';
/* eslint-disable @typescript-eslint/no-explicit-any */
/** Whose sign-in an opt-in copy takes: Apple's (App Store Connect) or Google's (Play Console). */
export type SessionProvider = 'apple' | 'google';
/** Per provider, the hosts whose cookies an opt-in copy keeps; a cookie is kept when its domain applies to one of them. */
export const PROVIDER_HOSTS: Record<SessionProvider, string[]> = {
apple: ['appstoreconnect.apple.com', 'idmsa.apple.com', 'apple.com'],
google: ['play.google.com', 'accounts.google.com'],
};
/** Both consoles' hosts (an opt-in copy always takes ONE provider's: PROVIDER_HOSTS). */
export const CONSOLE_HOSTS = [...PROVIDER_HOSTS.apple, ...PROVIDER_HOSTS.google];
/** The provider whose sign-in a store's console uses. */
export const storeProvider = (store: 'apple' | 'play'): SessionProvider => (store === 'play' ? 'google' : 'apple');
/** `--use-chrome-session <google|apple[,..]>`: the providers named; anything else is an error. Absent: none. */
export function parseUseChromeSession(v: string | boolean | undefined): SessionProvider[] {
if (v === undefined || v === false) return [];
const names = typeof v === 'string' ? v.split(',').map(x => x.trim().toLowerCase()).filter(Boolean) : [];
if (!names.length || names.some(n => n !== 'google' && n !== 'apple')) throw new Error('--use-chrome-session: google | apple (copies that sign-in from your Chrome; read the warning in the console-autofill skill first)');
return [...new Set(names)] as SessionProvider[];
}
/** Said before an opt-in copy of the owner's Google cookies (DBSC / cookie-theft sign-out, see the header). */
export const GOOGLE_COPY_WARNING = 'WARNING --use-chrome-session google: Google binds its sign-in cookies to your device (Device Bound Session Credentials) and signs out sessions whose cookies look copied. Using a copy of them in a second Chrome can make Google refuse the copy (/signin/rejected) AND sign you out of your real Chrome, or ask you to verify your account again. Without this flag storebound uses its own profile: sign in there once.';
/** Said before an opt-in copy of the owner's Apple cookies. */
export const APPLE_COPY_NOTE = 'note --use-chrome-session apple: Apple publishes no device binding for its web sign-in, but it does not rule out a security check either; if App Store Connect asks you to sign in or verify again, do that, and prefer storebound\'s own profile (run without this flag).';
/** Google refused the sign-in in this window (accounts.google.com/v3/signin/rejected and older /signin/.../rejected). */
export function googleRejected(url: string): boolean {
try {
const u = new URL(url);
return u.hostname === 'accounts.google.com' && /(^|\/)signin\/(v\d+\/)?rejected(\/|$)/i.test(u.pathname);
} catch { return false; }
}
/** What storebound says when Google refused the sign-in (googleRejected). */
export const GOOGLE_REJECTED = 'Google refused sign-in in this window (/signin/rejected). storebound uses a normal Chrome window; try again, or sign in at accounts.google.com first in this window.';
/** What to do the first time in storebound's own profile (the owner signs in by hand; storebound never types passwords). */
export function firstSignInSteps(store: 'apple' | 'play'): string {
return store === 'play'
? 'first run in this profile: in the window, sign in to the Google account that owns your Play developer account yourself (email, password, 2-step verification; storebound never types them), tick "Stay signed in" if asked. If Google answers "Couldn\'t sign you in", open accounts.google.com in that window and sign in there first. The sign-in is kept for the next runs.'
: 'first run in this profile: in the window, sign in to App Store Connect yourself (Apple ID, password, two-factor code; storebound never types them) and tick "Keep me signed in" / "Trust" this browser. The sign-in is kept for the next runs.';
}
/** Files of a Chrome profile that are never copied (passwords, history, extensions, form data ...). */
export const NEVER_COPY = /(^|\/)(Login Data|History|Extensions|Web Data|Bookmarks|Sessions|Preferences|Secure Preferences|Local Storage|IndexedDB|Account Web Data)/;
/** Google Chrome's user data dir on this OS. */
export function chromeDataDir(platform: string = process.platform, home: string = homedir(), env: Record<string, string | undefined> = process.env): string {
if (platform === 'darwin') return join(home, 'Library', 'Application Support', 'Google', 'Chrome');
if (platform === 'win32') return join(env.LOCALAPPDATA ?? join(home, 'AppData', 'Local'), 'Google', 'Chrome', 'User Data');
return join(env.XDG_CONFIG_HOME ?? join(home, '.config'), 'google-chrome');
}
/** The profile dir name: --chrome-profile, else Local State's last used profile, else Default. */
export function pickProfile(localState: string | null, requested?: string): string {
if (requested) {
if (!/^[\w .-]+$/.test(requested) || requested.includes('..')) throw new Error('--chrome-profile: a profile folder name such as "Default" or "Profile 1"');
return requested;
}
try { const last = JSON.parse(localState ?? '{}')?.profile?.last_used; if (typeof last === 'string' && /^[\w .-]+$/.test(last) && !last.includes('..')) return last; } catch { /* default */ }
return 'Default';
}
/**
* The files the copy takes, as [source relative to the user data dir, destination relative to the temp dir]: Local
* State and the profile's cookie store (Cookies, or Network/Cookies on newer layouts) with its journal. The profile
* always lands in `Default` of the temp dir. Nothing else, ever.
*/
export function sessionFiles(profile: string, exists: (rel: string) => boolean): [string, string][] {
const out: [string, string][] = [];
if (exists('Local State')) out.push(['Local State', 'Local State']);
for (const sub of ['', 'Network/']) {
const db = `${profile}/${sub}Cookies`;
if (!exists(db)) continue;
out.push([db, `Default/${sub}Cookies`]);
if (exists(`${db}-journal`)) out.push([`${db}-journal`, `Default/${sub}Cookies-journal`]);
}
for (const [s] of out) if (NEVER_COPY.test(s)) throw new Error(`session copy: refusing to copy ${s}`);
return out;
}
/** A cookie stored for `hostKey` (".google.com" or "play.google.com") is sent to one of `hosts`. */
export function cookieApplies(hostKey: string, hosts: string[] = CONSOLE_HOSTS): boolean {
const d = hostKey.replace(/^\./, '').toLowerCase();
if (!d.includes('.')) return false; // never a whole top-level domain
return hosts.some(h => h === d || h.endsWith(`.${d}`));
}
/** Deletes every cookie of the copied DB that the consoles never get. Returns counts only (never values or names). */
export async function filterCookies(dbPath: string, hosts: string[] = CONSOLE_HOSTS): Promise<{ kept: number; removed: number }> {
const { Database } = await import('bun:sqlite');
const db = new Database(dbPath);
try {
const keys = (db.query('SELECT DISTINCT host_key AS h FROM cookies').all() as { h: string }[]).map(r => r.h);
const drop = keys.filter(k => !cookieApplies(k, hosts));
const count = (db.query('SELECT COUNT(*) AS n FROM cookies').get() as { n: number }).n;
const del = db.prepare('DELETE FROM cookies WHERE host_key = ?');
db.transaction(() => { for (const k of drop) del.run(k); })();
const kept = (db.query('SELECT COUNT(*) AS n FROM cookies').get() as { n: number }).n;
db.exec('VACUUM');
return { kept, removed: count - kept };
} finally { db.close(); }
}
export interface SessionCopy { dir: string; profile: string; files: string[]; filtered: boolean; kept?: number; cleanup: () => void }
/**
* Copies the session files of `src` (a Chrome user data dir) into a new temp dir and filters the cookies to `hosts`.
* On any error the temp dir is removed before the error is thrown.
*/
export async function copySession(src: string, opts: { profile?: string; hosts?: string[]; tmpRoot?: string } = {}): Promise<SessionCopy> {
const ls = existsSync(join(src, 'Local State')) ? readFileSync(join(src, 'Local State'), 'utf8') : null;
const profile = pickProfile(ls, opts.profile);
const files = sessionFiles(profile, rel => existsSync(join(src, rel)));
if (!files.some(([s]) => s.endsWith('Cookies'))) throw new Error(`no Chrome cookie store in profile "${profile}"`);
const dir = mkdtempSync(join(opts.tmpRoot ?? tmpdir(), 'storebound-chrome-'));
const cleanup = () => {
// Chrome may still write a file while it exits: retry, and only stop once the folder is gone
for (let i = 0; i < 5 && existsSync(dir); i++) { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); } catch { Bun.sleepSync(100); } }
};
try {
for (const [s, d] of files) { mkdirSync(dirname(join(dir, d)), { recursive: true }); copyFileSync(join(src, s), join(dir, d)); }
let filtered = false, kept: number | undefined;
try {
for (const [, d] of files.filter(([, d]) => d.endsWith('Cookies'))) kept = (kept ?? 0) + (await filterCookies(join(dir, d), opts.hosts)).kept;
filtered = true;
} catch { /* the whole cookie store stays (said in the log) */ }
return { dir, profile, files: files.map(([s]) => s), filtered, ...(kept !== undefined ? { kept } : {}), cleanup };
} catch (e) { cleanup(); throw e; }
}
/** The DevTools endpoint of a Chrome running with remote debugging (its DevToolsActivePort file), or null. */
export function devToolsEndpoint(dataDir: string): string | null {
try {
const [port] = readFileSync(join(dataDir, 'DevToolsActivePort'), 'utf8').split('\n');
return port && /^\d+$/.test(port.trim()) ? `http://127.0.0.1:${port.trim()}` : null;
} catch { return null; }
}
const LOCAL_CDP = /^(https?|wss?):\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?(\/|$)/;
export type BrowserMode = 'attach' | 'copy' | 'profile';
export interface ConsoleBrowser {
mode: BrowserMode;
context: any;
/** The tab storebound works in. */
page: any;
/** The tabs storebound may look at (its own tab and the popups it opened; in copy / profile mode every tab). */
pages: () => any[];
/** Resolves when the owner closed the window (copy / profile) or storebound's tab (attach). */
closed: Promise<void>;
isClosed: () => boolean;
/** Closes gracefully (cookies flushed): the whole browser (copy / profile) or storebound's tab (attach). Max 10 s. */
close: () => Promise<void>;
/** Removes the temporary profile (copy mode); safe to call twice. */
cleanup: () => void;
/** Copy mode: the temporary profile (deleted by cleanup and when the window closes). */
tempDir?: string;
/** Copy / profile: the command line storebound started Chrome with (never automation switches). */
args?: string[];
}
export interface OpenOpts {
store: 'apple' | 'play';
/** --cdp: a local DevTools endpoint to attach to. */
cdp?: string;
/** --chrome-profile (only with useChromeSession). */
chromeProfile?: string;
/** --use-chrome-session: the providers whose Chrome sign-in the owner opted in to copy. Default: none (own profile). */
useChromeSession?: SessionProvider[];
/** --fresh-profile: skip attach and copy. */
fresh?: boolean;
/** storebound's own profile dir (the default). */
profileDir: string;
/** Overrides for tests: Chrome's user data dir, the hosts kept, headless, the platform. */
chromeDir?: string;
hosts?: string[];
headless?: boolean;
platform?: string;
/** Tests: how long the browser may take to start (default 30 s). */
launchTimeoutMs?: number;
/** Tests: Playwright's chromium and the installed browsers the launch resolver sees. */
launchDeps?: LaunchDeps;
say: (m: string) => void;
}
/** Runs a graceful close, at most `ms`; errors (already closed) are ignored. */
async function gracefully(fn: () => Promise<unknown>, ms = 10_000): Promise<void> {
await Promise.race([Promise.resolve().then(fn).catch(() => { /* already closed */ }), Bun.sleep(ms)]);
}
/**
* Launch switch of the copy and profile modes: bring back the last session's cookies (session cookies included,
* see the header). The restored tabs are closed again by ownTab.
*/
export const KEEP_SESSION_ARGS = ['--restore-last-session'];
/** No automation marker a page can see: navigator.webdriver stays false (also with --headless, used by tests only). */
export const NO_AUTOMATION_ARGS = ['--disable-blink-features=AutomationControlled'];
/** Switches storebound never passes to the console window (Playwright's launch adds the first). */
export const AUTOMATION_SWITCHES = ['--enable-automation', '--remote-debugging-pipe', '--use-mock-keychain', '--password-store=basic'];
/**
* The command line of the Chrome storebound starts itself for the console window: its own user data dir, a DevTools
* port the OS picks (written to <dir>/DevToolsActivePort, loopback only), session restore, no automation markers, no
* first-run dialogs. Nothing else: Chrome keeps its own user agent, keychain and updates.
*/
export function consoleChromeArgs(userDataDir: string, o: { headless?: boolean } = {}): string[] {
return [`--user-data-dir=${userDataDir}`, '--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check', ...KEEP_SESSION_ARGS, ...NO_AUTOMATION_ARGS, ...(o.headless ? ['--headless=new'] : [])];
}
/** One new tab for storebound; the tabs the session restore brought back are closed (they would compete for "the active tab"). */
async function ownTab(context: any): Promise<any> {
const restored: any[] = context.pages();
const page = await context.newPage();
for (const p of restored) await p.close().catch(() => { /* gone */ });
return page;
}
/** What the browser decision is, before anything starts (pure; tests). */
export function browserPlan(o: Pick<OpenOpts, 'store' | 'cdp' | 'fresh' | 'useChromeSession' | 'platform'>, chromeDirExists: boolean): { attach: boolean; copy: SessionProvider | null } {
const provider = storeProvider(o.store);
const copy = !o.fresh && (o.useChromeSession ?? []).includes(provider) && (o.platform ?? process.platform) === 'darwin' && chromeDirExists ? provider : null;
return { attach: !o.fresh, copy };
}
/**
* Starts `exe` on `userDataDir` (consoleChromeArgs) and attaches over CDP. Chrome is closed with CDP Browser.close (a
* normal shutdown: cookies flushed), SIGTERM after 10 s; the window counts as closed when Chrome exits or its last tab
* is closed (macOS keeps Chrome running without windows: then storebound closes it gracefully).
*/
async function startOwnChrome(root: string, mode: 'copy' | 'profile', exe: { label: string; path: string }, userDataDir: string, o: OpenOpts, cleanup: () => void): Promise<ConsoleBrowser> {
const holder = profileHolder(userDataDir);
if (holder) throw new Error(`the browser profile ${userDataDir} is still open in another browser (process ${holder}), probably an earlier storebound run that waits for its window to close. Close that window (or end that run) and start again`);
const portFile = join(userDataDir, 'DevToolsActivePort');
rmSync(portFile, { force: true });
const args = consoleChromeArgs(userDataDir, { headless: o.headless ?? false });
const proc = Bun.spawn([exe.path, ...args], { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' });
let exited = false;
const gone = proc.exited.then(() => { exited = true; });
const end = Date.now() + (o.launchTimeoutMs ?? 30_000);
let endpoint: string | null = null;
while (!endpoint && !exited && Date.now() < end) { endpoint = devToolsEndpoint(userDataDir); if (!endpoint) await Bun.sleep(100); }
if (!endpoint) {
if (!exited) { proc.kill('SIGTERM'); await Promise.race([gone, Bun.sleep(5000)]); }
throw new Error(`${exe.label} did not start${exited ? ` (exit code ${proc.exitCode})` : ' in time'}`);
}
let browser: any;
try {
const pw = o.launchDeps?.pw ?? await chromiumFor(root);
browser = await pw.connectOverCDP(endpoint, { timeout: Math.max(5000, end - Date.now()) });
} catch (e) {
proc.kill('SIGTERM'); await Promise.race([gone, Bun.sleep(5000)]);
throw new Error(`${exe.label}: could not attach (${(e as Error).message.split('\n')[0]})`, { cause: e });
}
const context = browser.contexts()[0] ?? await browser.newContext();
const close = async () => {
if (exited) return;
await gracefully(async () => { const s = await browser.newBrowserCDPSession(); await s.send('Browser.close'); });
await Promise.race([gone, Bun.sleep(10_000)]);
if (!exited) { proc.kill('SIGTERM'); await Promise.race([gone, Bun.sleep(5000)]); }
};
let closed = false;
const done = gone.then(async () => { closed = true; cleanup(); await Bun.sleep(300); cleanup(); });
const page = await ownTab(context);
const watch = (p: any) => p.on('close', () => { if (!exited && context.pages().length === 0) void close(); });
for (const p of context.pages()) watch(p);
context.on('page', watch);
return { mode, context, page, pages: () => context.pages(), closed: done, isClosed: () => closed, close, cleanup, args: [exe.path, ...args], ...(mode === 'copy' ? { tempDir: userDataDir } : {}) };
}
/** The executable for the console window: kind "visible" (own profile) or "chrome" (a copy: only real Chrome decrypts it). */
function executableFor(root: string, kind: 'visible' | 'chrome', o: OpenOpts): Promise<{ label: string; path: string }> {
return (async () => {
const installed = o.launchDeps?.installed ?? installedBrowsers(o.launchDeps?.pw ?? await chromiumFor(root).catch(() => null));
return persistentExecutable(kind, installed);
})();
}
/** Opens the browser: attach (a running Chrome with remote debugging), an opt-in copy, else storebound's own profile. */
export async function openConsoleBrowser(root: string, o: OpenOpts): Promise<ConsoleBrowser> {
const chromeDir = o.chromeDir ?? chromeDataDir(o.platform);
const plan = browserPlan(o, existsSync(chromeDir));
if (plan.attach) {
// (a) attach to a running Chrome with remote debugging: the owner's own browser, nothing copied
if (o.cdp && !LOCAL_CDP.test(o.cdp)) throw new Error('--cdp: only a local DevTools endpoint (http://127.0.0.1:<port>)');
const endpoint = o.cdp ?? devToolsEndpoint(chromeDir);
if (endpoint) {
try {
const c = o.launchDeps?.pw ?? await chromiumFor(root);
const browser = await c.connectOverCDP(endpoint, { timeout: 5000 });
const context = browser.contexts()[0] ?? await browser.newContext();
const page = await context.newPage();
const own = new Set<any>([page]);
page.on('popup', (p: any) => own.add(p));
let closed = false;
const done = new Promise<void>(r => page.on('close', () => { closed = true; r(); }));
o.say('browser: attached to your running Chrome (remote debugging); storebound works in one new tab and closes only that tab');
return { mode: 'attach', context, page, pages: () => [...own].filter(p => !p.isClosed()), closed: done, isClosed: () => closed, close: () => gracefully(() => page.close()), cleanup: () => { /* nothing copied */ } };
} catch (e) {
if (o.cdp) throw new Error(`--cdp: could not attach to ${o.cdp} (${(e as Error).message.split('\n')[0]})`);
o.say('browser: Chrome has a DevTools port file but storebound could not attach; using storebound\'s own profile');
}
}
}
const asked = (o.useChromeSession ?? []).filter(p => p === storeProvider(o.store));
if (asked.length && !plan.copy) o.say(`browser: --use-chrome-session ${asked[0]} needs macOS and Google Chrome's data folder (${chromeDir}); using storebound's own profile`);
if (plan.copy) {
// (b) OPT-IN: a copy of only this store's session cookies, opened in real Google Chrome
o.say(plan.copy === 'google' ? GOOGLE_COPY_WARNING : APPLE_COPY_NOTE);
let copy: SessionCopy | null = null;
try {
copy = await copySession(chromeDir, { ...(o.chromeProfile ? { profile: o.chromeProfile } : {}), hosts: o.hosts ?? PROVIDER_HOSTS[plan.copy] });
const exe = await executableFor(root, 'chrome', o);
const cp = copy;
const b = await startOwnChrome(root, 'copy', exe, cp.dir, o, () => cp.cleanup());
o.say(`browser: your Chrome sign-in for ${plan.copy === 'google' ? 'Google' : 'Apple'} (profile "${cp.profile}", opted in). storebound copied only ${cp.files.join(', ')} into a temporary profile`
+ `${cp.filtered ? `, kept only the ${plan.copy === 'google' ? 'Google' : 'Apple'} sign-in cookies (${cp.kept ?? 0})` : ' (the whole cookie file: it could not be filtered)'}`
+ ', and deletes it when this run ends. Passwords, history and extensions are never copied. macOS may ask once to let Google Chrome use "Chrome Safe Storage" in your Keychain: that is Chrome reading its own cookies.');
return b;
} catch (e) {
copy?.cleanup();
o.say(`browser: could not use your Chrome sign-in (${(e as Error).message.split('\n')[0]}); using storebound's own profile`);
}
}
// (default) storebound's own persistent profile, the owner signs in there once
const fresh = !existsSync(o.profileDir);
mkdirSync(o.profileDir, { recursive: true });
const exe = await executableFor(root, 'visible', o);
const b = await startOwnChrome(root, 'profile', exe, o.profileDir, o, () => { /* kept on purpose */ });
o.say(`browser: storebound's own profile (${o.profileDir}) in ${exe.label}, started as a normal Chrome window (no automation flags); nothing is copied from your own Chrome. Sign in there once, it is kept for the next run`);
if (fresh) o.say(firstSignInSteps(o.store));
return b;
}