storebound/cli/console/session.ts

396 lines
27 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.
// The Chrome storebound starts never outlives its run (cli/console/chrome-guard.ts, an October 2026 incident: an
// orphaned headless Chrome kept the owner's own Chrome from opening from the Dock): exit, Ctrl+C, SIGTERM / SIGHUP and
// a watchdog for the parent's death end it, and every start first ends the headless ones earlier runs left behind.
// 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, sandboxArgs } from '../shots/browser';
import { guardChrome, reapStaleChromes } from './chrome-guard';
import * as rt from '../lib/rt';
/* 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 }> {
// bun:sqlite under Bun, node:sqlite on Node 22.5+; without SQLite (Node 20) this throws and the caller says so
const db = await rt.openSqlite(dbPath);
try {
const keys = db.all<{ h: string }>('SELECT DISTINCT host_key AS h FROM cookies').map(r => r.h);
const drop = keys.filter(k => !cookieApplies(k, hosts));
const count = Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM cookies')!.n);
db.exec('BEGIN');
try { for (const k of drop) db.run('DELETE FROM cookies WHERE host_key = ?', k); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; }
const kept = Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM cookies')!.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 { rt.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 */ }), rt.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; --no-sandbox only where Chromium cannot start without it (root on Linux, a container:
* sandboxArgs). Nothing else: Chrome keeps its own user agent, keychain and updates.
*/
export function consoleChromeArgs(userDataDir: string, o: { headless?: boolean; sandbox?: string[] } = {}): string[] {
return [`--user-data-dir=${userDataDir}`, '--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check', ...KEEP_SESSION_ARGS, ...NO_AUTOMATION_ARGS,
...(o.sandbox ?? sandboxArgs()), ...(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> {
await reapStaleChromes({ say: o.say });
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 = rt.spawn([exe.path, ...args], { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' });
let exited = false;
// eslint-disable-next-line prefer-const
let close: () => Promise<void>;
// until it exits: process exit, a signal (graceful close, then the copy is removed) or this process dying ends it
const release = proc.pid ? guardChrome(proc.pid, `--user-data-dir=${userDataDir}`, async () => { await close(); cleanup(); }) : () => {};
const gone = proc.exited.then(() => { exited = true; release(); });
close = async () => { if (!exited) { proc.kill('SIGTERM'); await Promise.race([gone, rt.sleep(5000)]); } };
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 rt.sleep(100); }
if (!endpoint) {
if (!exited) { proc.kill('SIGTERM'); await Promise.race([gone, rt.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, rt.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();
let closing: Promise<void> | null = null;
close = () => (closing ??= (async () => {
if (exited) return;
await gracefully(async () => { const s = await browser.newBrowserCDPSession(); await s.send('Browser.close'); });
await Promise.race([gone, rt.sleep(10_000)]);
if (!exited) { proc.kill('SIGTERM'); await Promise.race([gone, rt.sleep(5000)]); }
})());
let closed = false;
const done = gone.then(async () => { closed = true; cleanup(); await rt.sleep(300); cleanup(); });
let page: any;
try { page = await ownTab(context); } catch (e) { await close(); throw e; } // never a running Chrome without its run
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, process.env, { headless: o.headless ?? false, platform: o.platform ?? process.platform });
})();
}
/** 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;
}