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.
This commit is contained in:
CRTkafa 2026-10-12 07:46:04 +08:00
parent e0bef82b06
commit 0035931734
22 changed files with 456 additions and 156 deletions

View file

@ -54,7 +54,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
- `skills/appstore-first-release/SKILL.md`: Step-by-step first (and later) App Store release of a mobile app or game
- `skills/aso-basics/SKILL.md`: App store optimization basics for a mobile app's or game's text
- `skills/build-and-sign/SKILL.md`: Sign, build and upload a mobile app or game from the terminal
- `skills/console-autofill/SKILL.md`: AI-assisted autofill of App Store Connect and Google Play Console forms that have no API (the New App dialog, App Privacy, Play's Create app form, the Play IARC content rating questionnaire, Target audience and content plus the Ads declaration; the App Store age rating is shown as guidance because the API sets it) in a visible browser that reuses the owner's Chrome sign-in (or one they sign in to); storebound opens the form itself. Answers come only from storebound.config.ts; anything the config does not prove stays empty and highlighted. storebound moves through the pages itself (Next; App Privacy: Get Started, Edit, each data type and the draft Save; content rating: Save draft) and never signs in, submits or publishes: the owner presses the final button
- `skills/console-autofill/SKILL.md`: AI-assisted autofill of App Store Connect and Google Play Console forms that have no API (the New App dialog, App Privacy, Play's Create app form, the Play IARC content rating questionnaire, Target audience and content plus the Ads declaration; the App Store age rating is shown as guidance because the API sets it) in a visible browser with storebound's own profile that the owner signs in to once (never a copy of the owner's Google cookies unless they opt in); storebound opens the form itself. Answers come only from storebound.config.ts; anything the config does not prove stays empty and highlighted. storebound moves through the pages itself (Next; App Privacy: Get Started, Edit, each data type and the draft Save; content rating: Save draft) and never signs in, submits or publishes: the owner presses the final button
- `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly
- `skills/listing-localization/SKILL.md`: Translate the store listing of a mobile app or game into more languages
- `skills/migrate-from-fastlane/SKILL.md`: Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane`

View file

@ -56,7 +56,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
| `appstore-first-release` | Step-by-step first (and later) App Store release of a mobile app or game |
| `aso-basics` | App store optimization basics for a mobile app's or game's text |
| `build-and-sign` | Sign, build and upload a mobile app or game from the terminal |
| `console-autofill` | AI-assisted autofill of App Store Connect and Google Play Console forms that have no API (the New App dialog, App Privacy, Play's Create app form, the Play IARC content rating questionnaire, Target audience and content plus the Ads declaration; the App Store age rating is shown as guidance because the API sets it) in a visible browser that reuses the owner's Chrome sign-in (or one they sign in to); storebound opens the form itself. Answers come only from storebound.config.ts; anything the config does not prove stays empty and highlighted. storebound moves through the pages itself (Next; App Privacy: Get Started, Edit, each data type and the draft Save; content rating: Save draft) and never signs in, submits or publishes: the owner presses the final button |
| `console-autofill` | AI-assisted autofill of App Store Connect and Google Play Console forms that have no API (the New App dialog, App Privacy, Play's Create app form, the Play IARC content rating questionnaire, Target audience and content plus the Ads declaration; the App Store age rating is shown as guidance because the API sets it) in a visible browser with storebound's own profile that the owner signs in to once (never a copy of the owner's Google cookies unless they opt in); storebound opens the form itself. Answers come only from storebound.config.ts; anything the config does not prove stays empty and highlighted. storebound moves through the pages itself (Next; App Privacy: Get Started, Edit, each data type and the draft Save; content rating: Save draft) and never signs in, submits or publishes: the owner presses the final button |
| `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly |
| `listing-localization` | Translate the store listing of a mobile app or game into more languages |
| `migrate-from-fastlane` | Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` |

View file

@ -55,7 +55,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
- `skills/appstore-first-release/SKILL.md`: Step-by-step first (and later) App Store release of a mobile app or game
- `skills/aso-basics/SKILL.md`: App store optimization basics for a mobile app's or game's text
- `skills/build-and-sign/SKILL.md`: Sign, build and upload a mobile app or game from the terminal
- `skills/console-autofill/SKILL.md`: AI-assisted autofill of App Store Connect and Google Play Console forms that have no API (the New App dialog, App Privacy, Play's Create app form, the Play IARC content rating questionnaire, Target audience and content plus the Ads declaration; the App Store age rating is shown as guidance because the API sets it) in a visible browser that reuses the owner's Chrome sign-in (or one they sign in to); storebound opens the form itself. Answers come only from storebound.config.ts; anything the config does not prove stays empty and highlighted. storebound moves through the pages itself (Next; App Privacy: Get Started, Edit, each data type and the draft Save; content rating: Save draft) and never signs in, submits or publishes: the owner presses the final button
- `skills/console-autofill/SKILL.md`: AI-assisted autofill of App Store Connect and Google Play Console forms that have no API (the New App dialog, App Privacy, Play's Create app form, the Play IARC content rating questionnaire, Target audience and content plus the Ads declaration; the App Store age rating is shown as guidance because the API sets it) in a visible browser with storebound's own profile that the owner signs in to once (never a copy of the owner's Google cookies unless they opt in); storebound opens the form itself. Answers come only from storebound.config.ts; anything the config does not prove stays empty and highlighted. storebound moves through the pages itself (Next; App Privacy: Get Started, Edit, each data type and the draft Save; content rating: Save draft) and never signs in, submits or publishes: the owner presses the final button
- `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly
- `skills/listing-localization/SKILL.md`: Translate the store listing of a mobile app or game into more languages
- `skills/migrate-from-fastlane/SKILL.md`: Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane`

View file

@ -2,6 +2,21 @@
## Unreleased
- Safety (`console fill`): storebound no longer copies the owner's Chrome cookies by default. A copied Google session
reached Play Console once, then Google answered `/v3/signin/rejected`: Google binds its session cookies to the
device (Chrome's Device Bound Session Credentials) and signs out sessions whose cookies look stolen, which can sign
the owner out of their real Chrome. Sources: blog.google/security/protecting-cookies-with-device-bound-session-credentials ,
knowledge.workspace.google.com/admin/security/investigate-and-take-action-on-suspicious-session-cookies .
- Default: storebound's own profile per store (`~/.storebound/console-profile/<store>`); the owner signs in once by
hand (the first run prints what to do) and it is kept.
- storebound starts that Chrome itself (`--user-data-dir`, `--remote-debugging-port=0`) and attaches over CDP: no
`--enable-automation`, `--disable-blink-features=AutomationControlled` (`navigator.webdriver` false), none of
Playwright's launch switches, Chrome's own user agent. Graceful close by CDP `Browser.close`.
- `--use-chrome-session google|apple`: explicit opt-in to the old copy, now of only that store's cookies; Google's
prints a warning about device-bound sessions. Apple's is opt-in too (Apple publishes no binding, but no guarantee).
- Google's `/signin/rejected` page is said plainly: "Google refused sign-in in this window".
- The desktop dialog no longer says it uses the Chrome sign-in. Pitfall 18.
- App Privacy purposes (wrong answers in `console fill apple-app-privacy`, `project.declarations` and `scan`): Play
"advertising" became both Third-Party Advertising and Developer's Advertising or Marketing (Device ID, Coarse
Location, Product Interaction, Performance Data). Now one Apple purpose per Play purpose, following Apple's

View file

@ -55,7 +55,7 @@ These rules apply to every AI tool that uses storebound (skills, MCP server or t
| `appstore-first-release` | Step-by-step first (and later) App Store release of a mobile app or game |
| `aso-basics` | App store optimization basics for a mobile app's or game's text |
| `build-and-sign` | Sign, build and upload a mobile app or game from the terminal |
| `console-autofill` | AI-assisted autofill of App Store Connect and Google Play Console forms that have no API (the New App dialog, App Privacy, Play's Create app form, the Play IARC content rating questionnaire, Target audience and content plus the Ads declaration; the App Store age rating is shown as guidance because the API sets it) in a visible browser that reuses the owner's Chrome sign-in (or one they sign in to); storebound opens the form itself. Answers come only from storebound.config.ts; anything the config does not prove stays empty and highlighted. storebound moves through the pages itself (Next; App Privacy: Get Started, Edit, each data type and the draft Save; content rating: Save draft) and never signs in, submits or publishes: the owner presses the final button |
| `console-autofill` | AI-assisted autofill of App Store Connect and Google Play Console forms that have no API (the New App dialog, App Privacy, Play's Create app form, the Play IARC content rating questionnaire, Target audience and content plus the Ads declaration; the App Store age rating is shown as guidance because the API sets it) in a visible browser with storebound's own profile that the owner signs in to once (never a copy of the owner's Google cookies unless they opt in); storebound opens the form itself. Answers come only from storebound.config.ts; anything the config does not prove stays empty and highlighted. storebound moves through the pages itself (Next; App Privacy: Get Started, Edit, each data type and the draft Save; content rating: Save draft) and never signs in, submits or publishes: the owner presses the final button |
| `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly |
| `listing-localization` | Translate the store listing of a mobile app or game into more languages |
| `migrate-from-fastlane` | Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` |

View file

@ -490,13 +490,15 @@ a terminal first and are never offered over MCP.
visible browser, goes to the form by itself (Apps > + > New App, the app's App Privacy, Play All apps > Create app,
Play app list > App content > Start / Manage;
navigation clicks only, never Save / Submit / Publish / Create / Next), fills what the config proves, highlights the
rest and stops before the final button: you click. **Sign-in:** it attaches to a Chrome running with remote
debugging, else (macOS) reuses your Chrome sign-in: it copies ONLY `Local State` and your profile's `Cookies`
(+ journal) into a temporary profile, keeps only the apple.com / google.com cookies the consoles use, opens real
Chrome on it and deletes it when the run ends (never passwords, history or extensions; cookies are never printed
or sent anywhere; macOS may ask once for Keychain access). `--fresh-profile` uses storebound's own profile instead
(one per store; the sign-in is kept between runs: session restore on launch, graceful close; tick "Keep me signed
in" / "Stay signed in"). It detects sign-in pages by URL, frame and text (Apple's starts with no password field),
rest and stops before the final button: you click. **Sign-in:** storebound's own profile
(one per store, `~/.storebound/console-profile/<store>`): you sign in there once by hand, it is kept between runs
(session restore on launch, graceful close; tick "Keep me signed in" / "Stay signed in"). storebound starts that
Chrome itself as a normal window (no `--enable-automation`, `navigator.webdriver` false) so Google accepts the
sign-in; if Google still answers `/signin/rejected` it says so. A Chrome running with remote debugging is used in
place. **It never copies your Google cookies by default:** Google binds them to your device (Chrome's Device Bound
Session Credentials) and signs out sessions whose cookies look copied, which can sign you out of your real Chrome.
`--use-chrome-session google|apple` is an explicit opt-in (a temporary copy of only that store's cookies, deleted at
the end) that prints this warning; see the console-autofill skill. It detects sign-in pages by URL, frame and text (Apple's starts with no password field),
waits while you sign in and then opens the form from step 1; after printing manual steps it keeps watching and picks
up where you are. **One login, many studios:** it finds the Play developer account (by `play.packageName`, or
`play.developerId`) and the App Store Connect team (account menu; `apple.teamId`, or by bundle id) that has the

View file

@ -261,7 +261,7 @@ pub fn dialog_labels(kind: Kind, title: &str) -> (String, String) {
/// the uncertain ones marked for the owner, and the note that the owner reviews and saves in the browser.
pub fn console_fill_dialog(project: &str, data: &Value) -> (String, String, String) {
let title = data["title"].as_str().unwrap_or("Console form");
let mut s = format!("{title} · {project}\n\nA browser window opens on the console page. It uses your Chrome sign-in: storebound copies only your Chrome cookies for App Store Connect and Play Console into a temporary profile and deletes it when the window closes (passwords, history and extensions are never copied; macOS may ask once for Keychain access). If you are not signed in, sign in there yourself. storebound opens the form, fills the fields below and marks the ones you answer.\n\n{}\n", policy::CONSOLE_FILL_NOTE);
let mut s = format!("{title} · {project}\n\nA browser window opens on the console page. It uses storebound's own browser profile: the first time, sign in there yourself (storebound never types passwords); the sign-in is kept for the next runs. Your own Chrome's cookies are never copied (Google ties them to your device and could sign you out). storebound opens the form, fills the fields below and marks the ones you answer.\n\n{}\n", policy::CONSOLE_FILL_NOTE);
let answers = data["answers"].as_array().cloned().unwrap_or_default();
let mut lines: Vec<String> = answers
.iter()

View file

@ -72,7 +72,7 @@ fn console_fill_is_a_dry_run_from_the_window_and_its_dialog_lists_the_answers()
assert_eq!(title, "Fill in console: Age rating");
assert_eq!(ok, "Open the browser");
assert!(text.contains("Prism Puddle") && text.contains(CONSOLE_FILL_NOTE));
assert!(text.contains("uses your Chrome sign-in") && text.contains("deletes it when the window closes"));
assert!(text.contains("uses storebound's own browser profile") && text.contains("cookies are never copied"));
assert!(text.contains("- Cartoon violence: None") && text.contains("- Simulated gambling: you answer this"));
let (_, empty, _) = console_fill_dialog("A", &json!(null));
assert!(empty.contains("every field is yours"));

View file

@ -1300,7 +1300,7 @@ export const en = {
'cf.panelTitle': 'Fill in console: {form}',
'cf.disclaimer': 'Console pages change. This is AI-assisted: review every field in the browser and press the final button (Publish, Submit, Save or Create) yourself. storebound moves through the pages for you and never submits or publishes; App Privacy answers stay a draft until you press Publish.',
'cf.notOpened': 'You chose Cancel in the dialog: no browser was opened.',
'cf.runningText': 'The browser is open with your Chrome sign-in (if it asks, sign in there yourself). storebound opens the form, fills the fields and marks the ones you answer. Review every field, press Save yourself, then close the browser.',
'cf.runningText': 'The browser is open with storebound\'s own profile (the first time, sign in there yourself; it is kept for the next runs). storebound opens the form, fills the fields and marks the ones you answer. Review every field, press Save yourself, then close the browser.',
'cf.doneText': 'Browser closed. Filled: {filled}, marked for you: {highlighted}. Check in the console that your answers are saved.',
'cf.closed': 'Browser closed.',
'cf.cancelled': 'Stopped: the browser was closed.',

View file

@ -1302,7 +1302,7 @@ export const tr: Record<keyof typeof en, string> = {
'cf.panelTitle': 'Konsolda doldur: {form}',
'cf.disclaimer': 'Konsol sayfaları değişebilir. Bu, yapay zekâ destekli bir yardımdır: tarayıcıda her alanı kontrol edin ve son düğmeye (Yayınla, Gönder, Kaydet veya Oluştur) kendiniz basın. storebound sayfalar arasında sizin yerinize ilerler, hiçbir zaman göndermez veya yayınlamaz; Uygulama Gizliliği yanıtları siz Yayınla\'ya basana kadar taslak kalır.',
'cf.notOpened': 'İletişim kutusunda Vazgeç\'i seçtiniz: tarayıcı açılmadı.',
'cf.runningText': 'Tarayıcı Chrome oturumunuzla açık (isterse orada kendiniz oturum açın). storebound formu açar, alanları doldurur ve yanıtı size kalanları işaretler. Her alanı kontrol edin, Kaydet\'e kendiniz basın ve ardından tarayıcıyı kapatın.',
'cf.runningText': 'Tarayıcı storebound\'un kendi profiliyle açık (ilk seferde orada kendiniz oturum açın; sonraki çalıştırmalar için saklanır). storebound formu açar, alanları doldurur ve yanıtı size kalanları işaretler. Her alanı kontrol edin, Kaydet\'e kendiniz basın ve ardından tarayıcıyı kapatın.',
'cf.doneText': 'Tarayıcı kapandı. Doldurulan: {filled}, sizin için işaretlenen: {highlighted}. Yanıtlarınızın kaydedildiğini konsolda kontrol edin.',
'cf.closed': 'Tarayıcı kapandı.',
'cf.cancelled': 'Durduruldu: tarayıcı kapatıldı.',

View file

@ -1,7 +1,7 @@
// AI-assisted autofill of console forms that have no API, in a visible Chromium the owner signs in to:
// storebound console fill lists the forms
// storebound console fill <form> --dry [--json] the answers and the step-by-step guidance; opens no browser
// storebound console fill <form> [--store apple|play] [--json] [--profile <dir>]
// storebound console fill <form> [--store apple|play] [--json] [--profile <dir>] [--use-chrome-session google|apple]
// Forms: apple-app-record, apple-age-rating (guide only: `storebound apple appinfo` sets it), apple-app-privacy,
// play-app-record (Play Console's Create app form: the Play Developer API cannot create apps), play-content-rating,
// play-target-audience. Answers come from the config only (cli/lib/console-answers.ts); anything
@ -19,9 +19,11 @@
// final button; the command ends when the window is closed. Locators are accessible names only
// (cli/lib/console-forms.ts); a missing or ambiguous field means the UI changed: filling stops and the guidance is
// printed instead. Not exposed over MCP.
// Browser (cli/console/session.ts): the owner's running Chrome (--cdp / remote debugging), else a temporary copy of only
// their Chrome sign-in cookies (macOS; deleted at the end), else storebound's own profile
// (~/.storebound/console-profile/<store>, or --profile / STOREBOUND_CONSOLE_PROFILE / --fresh-profile).
// Browser (cli/console/session.ts): storebound's own profile (~/.storebound/console-profile/<store>, or --profile /
// STOREBOUND_CONSOLE_PROFILE) in a Chrome storebound starts without automation switches; the owner's running Chrome
// when it has remote debugging (--cdp); a copy of the owner's Chrome sign-in only with --use-chrome-session google|apple
// (opt-in: Google binds its cookies to the device and can sign the owner out of the real Chrome).
// Google's /signin/rejected page is said plainly (GOOGLE_REJECTED).
// Sources (checked October 2026): see cli/lib/console-answers.ts; Playwright launchPersistentContext, getByRole,
// getByLabel: https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context ,
// https://playwright.dev/docs/locators
@ -36,7 +38,7 @@ import { log, printJson, warn } from '../lib/log';
import { openAcross, withAccountIds } from './accounts';
import { appleAppId, withAppleAppId } from './app-id';
import { bareUrl } from './debug';
import { type ConsoleBrowser, openConsoleBrowser } from './session';
import { type ConsoleBrowser, GOOGLE_REJECTED, googleRejected, openConsoleBrowser, parseUseChromeSession } from './session';
/* eslint-disable @typescript-eslint/no-explicit-any */
export type Page = any;
@ -741,7 +743,7 @@ export async function run(ctx: Ctx): Promise<void> {
if (!form) {
const forms = FORM_IDS.map(id => { const m = formMeta(id, config); return { id, store: m.store, title: m.title, fillable: m.fillable }; });
if (json) return printJson({ forms });
console.log('storebound console fill <form> [--store apple|play] [--dry] [--json] [--profile <dir>] [--step-timeout <seconds>] [--debug-dump]\n');
console.log('storebound console fill <form> [--store apple|play] [--dry] [--json] [--profile <dir>] [--use-chrome-session google|apple] [--step-timeout <seconds>] [--debug-dump]\n');
for (const f of forms) console.log(` ${f.id.padEnd(22)} ${f.store.padEnd(6)} ${f.title}${f.fillable ? '' : ' (guide only)'}`);
return;
}
@ -765,6 +767,7 @@ export async function run(ctx: Ctx): Promise<void> {
const b = await openConsoleBrowser(root, {
store: meta.store, profileDir: dir, say, fresh: args.has('fresh-profile') || ownProfile,
...(args.get('cdp') ? { cdp: args.get('cdp') } : {}), ...(args.get('chrome-profile') ? { chromeProfile: args.get('chrome-profile') } : {}),
useChromeSession: parseUseChromeSession(args.get('use-chrome-session') ?? (args.has('use-chrome-session') ? true : undefined)),
});
// Ctrl+C: close the browser gracefully (Chromium writes its cookies on a clean exit, so the sign-in is kept for the
// next run), then remove the temporary profile of copy mode; that also happens on every other exit path
@ -887,7 +890,7 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
async function signedIn(): Promise<boolean> {
const start = Date.now(), end = start + signInMs, first = firstWait;
firstWait = false;
let told = false, ok = 0, beat = start, watched = false, entered = false;
let told = false, ok = 0, beat = start, watched = false, entered = false, rejected = false;
const signInDump = async (p: Page | undefined, label: string) => {
if (!o.debugEveryStep || !p || signInDumps >= 3) return;
signInDumps++;
@ -896,6 +899,7 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
while (!b.isClosed() && Date.now() < end) {
const p = active();
const w = p ? await signInWhy(p) : null;
if (p && !rejected && googleRejected(p.url())) { rejected = true; say(GOOGLE_REJECTED); }
const state = (): string => !p ? 'no tab open' : `${bareUrl(p.url()) || p.url()}: ${w ? `a sign-in page (${w.rule})` : reached(p) ? 'the console' : 'neither a sign-in page nor the console yet'}`;
// the wait really starts: a sign-in page, or a page that is still not the console after a few rounds
if (p && !entered && (w || (!reached(p) && Date.now() - start >= 3 * tick))) { entered = true; await signInDump(p, 'sign-in wait'); }
@ -914,7 +918,7 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
sayWhy(w);
if (!told) {
told = true;
if (first && b.mode === 'copy' && isSignInUrl(p.url())) say(`your Chrome session for ${consoleName} did not carry over (expired or tied to this device); sign in once in this window, or run with --fresh-profile`);
if (first && b.mode === 'copy' && isSignInUrl(p.url())) say(`your copied Chrome session for ${consoleName} did not carry over (expired or tied to your own Chrome); sign in once in this window, or run without --use-chrome-session`);
signInHint();
}
} else if (p && reached(p)) { if (++ok >= 2) { if (told) say('signed in'); lastWhy = ''; return true; } }

View file

@ -1,39 +1,92 @@
// The browser `storebound console fill` works in, picked so the owner signs in as rarely as possible (the log says which):
// 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.
// (b) copy (default on macOS when Google Chrome is installed): a temporary profile with ONLY `Local State` and the
// profile's `Cookies` (+ `Cookies-journal`), filtered down to the cookies App Store Connect and Play Console use
// (apple.com, google.com domains: appstoreconnect / idmsa / play / accounts), opened in real Google Chrome
// (Chrome 136+ refuses automation on its default user data dir, a copy is fine). Chrome decrypts the cookies with
// its own Keychain key ("Chrome Safe Storage"); macOS may ask once to allow that. Never copied: passwords
// (Login Data), history, extensions, anything else. The temporary profile is deleted when the run ends (also on
// an error, Ctrl+C or when the desktop app cancels). `--chrome-profile <dir name>` picks a profile ("Profile 1");
// default: the last used one. `--fresh-profile` skips (a) and (b).
// (c) storebound's own persistent profile (~/.storebound/console-profile/<store>): the owner signs in there once.
// Cookies are never printed, logged or sent anywhere: only file names and counts are reported.
// Keeping the sign-in (b, c): App Store Connect's and Google's sign-in cookies are partly SESSION cookies (no expiry).
// 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 (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). Copy mode: the owner's Chrome Cookies file holds the session
// cookies only while that Chrome runs, or after it quit with "On startup: Continue where you left off" on (a normal
// Chrome start without it deletes them); otherwise the owner signs in once in the window storebound opens.
// The session.restore_on_startup preference is not written: it is a tracked (MAC-protected) Chrome preference, and an
// edit from outside can be reset by Chrome; the switch does the same for this launch only.
// Sources (checked October 2026): 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 / ignoreDefaultArgs
// https://playwright.dev/docs/api/class-browsertype
// 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, type LaunchDeps, launchPersistent } from '../shots/browser';
import { chromiumFor, installedBrowsers, type LaunchDeps, persistentExecutable, profileHolder } from '../shots/browser';
/* eslint-disable @typescript-eslint/no-explicit-any */
/** Hosts whose cookies the consoles need; a cookie is kept when its domain applies to one of them. */
export const CONSOLE_HOSTS = ['appstoreconnect.apple.com', 'idmsa.apple.com', 'apple.com', 'play.google.com', 'accounts.google.com'];
/** 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)/;
@ -151,52 +204,57 @@ export interface ConsoleBrowser {
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. */
/** --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 (mode c). */
/** 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: Playwright's launch timeout for modes (b) and (c) (default: Playwright's 30 s). */
/** 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;
}
function wrapContext(mode: BrowserMode, context: any, page: any, cleanup: () => void): ConsoleBrowser {
let closed = false;
const done = new Promise<void>(r => context.on('close', async () => {
closed = true;
cleanup();
await Bun.sleep(300); // a late write of the exiting browser
cleanup();
r();
}));
return { mode, context, page, pages: () => context.pages(), closed: done, isClosed: () => closed, close: () => gracefully(() => context.close()), cleanup };
}
/** 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 switches of the copy and profile modes: bring back the last session's cookies (session cookies included,
* 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> {
@ -206,11 +264,72 @@ async function ownTab(context: any): Promise<any> {
return page;
}
/** Opens the browser in the first mode that works: attach, copy, then storebound's own profile. */
/** 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);
if (!o.fresh) {
// (a) attach to a running Chrome with remote debugging
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) {
@ -227,35 +346,36 @@ export async function openConsoleBrowser(root: string, o: OpenOpts): Promise<Con
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; trying your Chrome sign-in instead');
}
}
// (b) a copy of only the session files, opened in real Google Chrome
if ((o.platform ?? process.platform) === 'darwin' && existsSync(chromeDir)) {
let copy: SessionCopy | null = null;
try {
copy = await copySession(chromeDir, { ...(o.chromeProfile ? { profile: o.chromeProfile } : {}), ...(o.hosts ? { hosts: o.hosts } : {}) });
const context = await launchPersistent(root, copy.dir, {
headless: o.headless ?? false, viewport: null, args: KEEP_SESSION_ARGS, ...(o.launchTimeoutMs ? { timeout: o.launchTimeoutMs } : {}),
// Playwright's mock keychain would hide Chrome's own key, and the cookies could not be read
ignoreDefaultArgs: ['--use-mock-keychain', '--password-store=basic'],
}, { ...o.launchDeps, kind: 'chrome' }); // only real Google Chrome decrypts the owner's cookies
o.say(`browser: your Chrome sign-in (profile "${copy.profile}"). storebound copied only ${copy.files.join(', ')} into a temporary profile`
+ `${copy.filtered ? `, kept only the cookies for App Store Connect and Play Console (${copy.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. Opt out: --fresh-profile');
const page = await ownTab(context);
const cp = copy;
return { ...wrapContext('copy', context, page, () => cp.cleanup()), tempDir: cp.dir };
} 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`);
o.say('browser: Chrome has a DevTools port file but storebound could not attach; using storebound\'s own profile');
}
}
}
// (c) storebound's persistent profile, the owner signs in there
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 context = await launchPersistent(root, o.profileDir, { headless: o.headless ?? false, viewport: null, args: KEEP_SESSION_ARGS, ...(o.launchTimeoutMs ? { timeout: o.launchTimeoutMs } : {}) }, { ...o.launchDeps, kind: 'visible' });
o.say(`browser: storebound's own profile (${o.profileDir}); sign in there once, it is kept for the next run`);
const page = await ownTab(context);
return wrapContext('profile', context, page, () => { /* kept on purpose */ });
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;
}

View file

@ -291,7 +291,8 @@
"profile",
"start-url",
"step-timeout",
"store"
"store",
"use-chrome-session"
],
"desktop": [
"channel",
@ -805,6 +806,7 @@
"unity",
"update-bigmac",
"upload",
"use-chrome-session",
"variant",
"variants",
"vendor",
@ -971,7 +973,7 @@
"console fill": [
"storebound console fill lists the forms",
"storebound console fill <form> --dry [--json] the answers and the step-by-step guidance; opens no browser",
"storebound console fill <form> [--store apple|play] [--json] [--profile <dir>]"
"storebound console fill <form> [--store apple|play] [--json] [--profile <dir>] [--use-chrome-session google|apple]"
],
"desktop": [
"storebound desktop open the app; in an app folder (storebound.config.*) it adds or selects that",

View file

@ -97,7 +97,7 @@ export const DESKTOP_ONLY: ToolDef[] = [
{ name: 'console_fill', title: 'Fill in a console form', command: ['console', 'fill'], write: true, network: true,
description: 'Fills one store console form that has no API (App Store app record, age rating, App Privacy; Play content rating, target audience) from the config in a visible Chromium the owner signs in to; uncertain fields are highlighted. It moves through the pages itself (Next; App Privacy: Get Started, Edit, each data type and the draft Save in its dialogs; content rating: Save draft) and never submits or publishes: the owner reviews every field and presses the final button (Publish, Submit, Save or Create). The dry run lists the answers; the real run lasts until the owner closes the browser.',
props: { form: { type: 'string', enum: CONSOLE_FORMS, description: 'The console form' }, store: { type: 'string', enum: ['apple', 'play'], description: 'Default: from the form' },
fresh_profile: { type: 'boolean', default: false, description: "Use storebound's own browser profile (sign in there) instead of the owner's Chrome sign-in (a temporary copy of only the console cookies, deleted at the end)" } },
fresh_profile: { type: 'boolean', default: false, description: "Skip attaching to a Chrome that runs with remote debugging. storebound's own browser profile (the owner signs in there once) is always used otherwise; the owner's Chrome cookies are never copied here" } },
required: ['form'], args: i => [String(i.form), ...opt('store', i.store), ...(i.fresh_profile === true ? ['--fresh-profile'] : [])] },
{ name: 'prices_current', title: 'Current prices', command: ['prices', 'export'], write: false, network: true,
description: 'Read-only: the current price of every product on both stores per region (Apple manual prices, with with_automatic also the equalized ones). Writes nothing.',

View file

@ -9,8 +9,10 @@
// - visible / persistent windows (`console fill`): STOREBOUND_CHROME, Google Chrome (channel "chrome"), then
// Playwright's full Chromium (channel "chromium"). Never the headless shell: it has no window, and it drops session
// cookies (no --restore-last-session), so a sign-in would not survive.
// - "chrome" (console fill's copy of the owner's Chrome sign-in: only real Google Chrome can decrypt those cookies
// with its own Keychain key): Google Chrome only.
// - "chrome" (console fill's opt-in copy of the owner's Chrome sign-in: only real Google Chrome can decrypt those
// cookies with its own Keychain key): Google Chrome only.
// `console fill` itself does not launch through Playwright: it takes the executable from `persistentExecutable` (same
// order) and starts Chrome on its own (cli/console/session.ts), so the window carries no automation switches.
// A candidate that is not installed is skipped; one that IS installed but fails to start stops the search with its own
// error (an earlier "Executable doesn't exist" never hides it). A persistent profile another browser still holds (its
// SingletonLock names a live process) is reported as such before anything is launched.
@ -149,6 +151,22 @@ export interface LaunchDeps {
installed?: Installed;
}
/**
* The executable `console fill` starts itself (no Playwright launch, so no automation switches): kind "visible" =
* STOREBOUND_CHROME, Google Chrome, then Playwright's full Chromium; kind "chrome" = Google Chrome only. Never the
* headless shell. Nothing installed: the same error as withCandidates.
*/
export function persistentExecutable(kind: 'visible' | 'chrome', i: Installed, env: Record<string, string | undefined> = process.env): { label: string; path: string } {
const list: { label: string; path: string }[] = [];
if (kind === 'visible' && i.storeboundChrome) list.push({ label: `STOREBOUND_CHROME (${i.storeboundChrome})`, path: i.storeboundChrome });
if (i.chrome) list.push({ label: `Google Chrome (${i.chrome})`, path: i.chrome });
if (kind === 'visible' && i.chromium) list.push({ label: `Playwright Chromium (${i.chromium})`, path: i.chromium });
if (list[0]) return list[0];
const skipped = env.STOREBOUND_CHROME && !i.storeboundChrome && kind !== 'chrome' ? `; STOREBOUND_CHROME ${env.STOREBOUND_CHROME}: no such file` : '';
throw new Error(`no usable browser (looked for ${LOOKED_FOR[kind]})${skipped}. `
+ (kind === 'chrome' ? 'Install Google Chrome.' : 'Install Google Chrome, or run `bunx playwright install chromium` in your project; `storebound doctor` lists what is missing.'));
}
/** A browser for renders and captures (headless unless `headless: false`). */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function launch(root: string, opts: Record<string, unknown>, deps: LaunchDeps = {}): Promise<any> {

View file

@ -199,11 +199,12 @@ write, never an MCP tool):
run lasts until the owner closes the browser: no timeout in serve, the runner or the host. Progress lines show in
the panel; **Cancel: close the browser** sends `$/cancelRequest`, which stops the command and its browser.
The browser is the same as in the terminal (cli/console/session.ts): a Chrome running with remote debugging, else on
macOS the owner's Chrome sign-in (real Google Chrome on a temporary copy of only `Local State` and the console cookies,
deleted when the run ends or is cancelled; the native dialog says "It uses your Chrome sign-in"), else storebound's own
profile in the desktop's Chrome for Testing (`STOREBOUND_CHROME`) with the bundled playwright-core. The tool's
`fresh_profile: true` passes `--fresh-profile`. storebound opens the form itself after sign-in (navigation clicks only).
The browser is the same as in the terminal (cli/console/session.ts): a Chrome running with remote debugging, else
storebound's own profile (the owner signs in there once; the native dialog says so) in the desktop's Chrome for
Testing (`STOREBOUND_CHROME`), started by storebound without automation switches and attached with the bundled
playwright-core. The desktop never copies the owner's Chrome cookies (Google binds them to the device; see the
console-autofill skill); `--use-chrome-session` is a terminal-only opt-in. The tool's `fresh_profile: true` passes
`--fresh-profile` (skip attach). storebound opens the form itself after sign-in (navigation clicks only).
The own profile is one per store (every studio shares the one login) and keeps the sign-in between runs: launched with
session restore, closed gracefully on Cancel. With one login in several Play developer accounts or App Store Connect
teams, the run finds the account that has the app and remembers it in `~/.storebound/console-accounts.json`

View file

@ -9,6 +9,7 @@ fails without the fix.
| # | date | lesson | guard |
|---|---|---|---|
| 18 | 2026-10 | `console fill play-app-record` with a copy of the owner's Chrome cookies reached the Play account chooser once, then Google answered `/v3/signin/rejected` and the chooser: Google session cookies are device-bound (Chrome's Device Bound Session Credentials) and Google signs out sessions whose cookies look stolen, which can also sign the owner out of their real Chrome. | Never copy Google cookies by default: storebound's own profile is the default (the owner signs in once by hand), started by storebound as a normal Chrome (no `--enable-automation`, `navigator.webdriver` false) and attached over CDP; `--use-chrome-session google\|apple` is an explicit opt-in that warns; `/signin/rejected` is said plainly (`cli/console/session.ts`, `tests/console-session.test.ts`). |
| 17 | 2026-10 | After the data types were saved, the real App Privacy page shows each type's "Set Up <type>" as a plain `<p>` in a div with a click handler (no button role, no link) and puts the types in a box after the Data Types heading's box: role-based locators and the "nearest heading" check found nothing, so no type was set up. A "?" (More information) button sits next to each type. | `setUpAllowed` / `setUpTexts` (`cli/lib/console-forms.ts`: exact text "Set Up <type>" of a type storebound answers, in the Data Types section, outside dialogs) and `setUpClick` / `setUpType` / `flowWhere` (last heading before the control) in `cli/console/fill.ts`; the per-type dialog steps are `page` sections, so an unknown step dumps and pauses instead of ending the run; `tests/fixtures/console/apple-app-privacy-setup.html` (structure of the real page) and `tests/console-flow.test.ts`. |
| 16 | 2026-10 | `console fill play-app-record` with the copied Chrome cookies printed only "still waiting for the sign-in" for 5+ minutes on a signed-in Play Console: Google pages embed `accounts.google.com` frames (the account widget, cookie rotation), and the detector counted any frame on a sign-in host. | `signInReason`: a sign-in page only by its top-level URL, a visible password / email-entry form in the main frame, or a visible Apple sign-in frame (idmsa / appleid); other frames never count. Each detection logs "sign-in detected because: <rule> at <url>"; heartbeats name the page; a copied session that lands on a sign-in URL is said plainly ("did not carry over"); the watchdog dumps the sign-in wait once after `--step-timeout`, `--debug-dump` up to 3 times (`tests/console-fill.test.ts`, chooser / home with the widget frames, a top-level accounts.google.com page). |
| 15 | 2026-10 | The real App Privacy "Data Collection" dialog names each data type checkbox by its label run together with its help text ("NameIncluding first or last name"), and labels some types differently from Apple's help page ("Purchases", "Other Data"): exact-name matching found none. | `labelNames` (exact name, then the label as a prefix that ends where the help text starts) and `TYPE_ALIASES` in `cli/lib/console-forms.ts`; the flow fixture mirrors the real dialog's structure (`tests/console-flow.test.ts`). |

View file

@ -1,6 +1,6 @@
---
name: console-autofill
description: AI-assisted autofill of App Store Connect and Google Play Console forms that have no API (the New App dialog, App Privacy, Play's Create app form, the Play IARC content rating questionnaire, Target audience and content plus the Ads declaration; the App Store age rating is shown as guidance because the API sets it) in a visible browser that reuses the owner's Chrome sign-in (or one they sign in to); storebound opens the form itself. Answers come only from storebound.config.ts; anything the config does not prove stays empty and highlighted. storebound moves through the pages itself (Next; App Privacy: Get Started, Edit, each data type and the draft Save; content rating: Save draft) and never signs in, submits or publishes: the owner presses the final button. Use when the user wants help filling one of these console forms, asks what to answer in them, or wants the answers as a checklist.
description: AI-assisted autofill of App Store Connect and Google Play Console forms that have no API (the New App dialog, App Privacy, Play's Create app form, the Play IARC content rating questionnaire, Target audience and content plus the Ads declaration; the App Store age rating is shown as guidance because the API sets it) in a visible browser with storebound's own profile that the owner signs in to once (never a copy of the owner's Google cookies unless they opt in); storebound opens the form itself. Answers come only from storebound.config.ts; anything the config does not prove stays empty and highlighted. storebound moves through the pages itself (Next; App Privacy: Get Started, Edit, each data type and the draft Save; content rating: Save draft) and never signs in, submits or publishes: the owner presses the final button. Use when the user wants help filling one of these console forms, asks what to answer in them, or wants the answers as a checklist.
---
# Console autofill (forms without an API)
@ -11,8 +11,10 @@ CLI: `storebound` (or `bunx storebound` in a project that has it, or `bun <toolc
answer in the wrong place. The owner reviews every field and presses the final button. storebound never types a
password, never touches a sign-in page, never prints or sends a cookie, and never clicks Submit, Publish, Create, a
final Save or anything that sends a form for review (English or Turkish console). It clicks only what moves through
the form (see "What storebound clicks" below). It reuses the owner's
Chrome sign-in through a temporary copy of only the console cookies (deleted at the end; `--fresh-profile` opts out).
the form (see "What storebound clicks" below). It works in its own
browser profile where the owner signs in once by hand. It never copies the owner's Google cookies unless the owner
opts in (`--use-chrome-session google`), because Google binds them to the device and may sign the owner out of their
real Chrome (step 3).
It is not available over MCP.
## Forms
@ -41,27 +43,44 @@ It is not available over MCP.
```
Age rating answers live in `apple.info.ageRating` (also `socialMedia`, `socialMediaAgeRestricted`).
3. The owner runs `storebound console fill <form>` in a terminal (or presses the button in the desktop app). The
first log line says which browser it uses, in this order:
first log line says which browser it uses:
- **profile** (the default): storebound's own profile (`~/.storebound/console-profile/<store>`, or `--profile <dir>`
/ `STOREBOUND_CONSOLE_PROFILE`; one per store, not per account: every studio shares the one login). Nothing is
read or copied from the owner's own Chrome. storebound starts Chrome itself as a normal window (its own
`--user-data-dir`, a DevTools port on 127.0.0.1, no `--enable-automation`, `navigator.webdriver` false, Chrome's
own user agent) and attaches to it. **First run:** the owner signs in there by hand, once (storebound never
types passwords): Play: the Google account that owns the developer account, then "Stay signed in"; App Store
Connect: Apple ID, password, two-factor code, "Keep me signed in" and "Trust" this browser. It is kept for the
next runs. If Google shows "Couldn't sign you in" (`/signin/rejected`), storebound says "Google refused sign-in in
this window"; try again, or open accounts.google.com in that window and sign in there first.
- **attach**: Chrome already runs with remote debugging (a `DevToolsActivePort` file in Chrome's user data folder,
or `--cdp http://127.0.0.1:<port>`): storebound opens one new tab there and closes only that tab.
- **copy** (default on macOS with Google Chrome installed): the owner's existing Chrome sign-in, no second sign-in.
storebound copies ONLY `Local State` and the profile's `Cookies` (+ `Cookies-journal`) into a temporary folder,
deletes every cookie there except the ones App Store Connect and Play Console get (apple.com and google.com
domains), and opens real Google Chrome on that copy. Never copied: passwords (`Login Data`), history, extensions,
bookmarks or anything else. The temporary folder is deleted when the run ends (window closed, error, Ctrl+C, or
Cancel in the desktop app). Chrome decrypts the cookies with its own Keychain key ("Chrome Safe Storage"); macOS
may ask once to allow that. `--chrome-profile "Profile 1"` picks another Chrome profile (default: the last used).
Cookies are never printed, logged or sent anywhere.
- **profile** (`--fresh-profile`, `--profile <dir>`, `STOREBOUND_CONSOLE_PROFILE`, or when the other two do not
work): storebound's own profile (`~/.storebound/console-profile/<store>`, one per store, not per account: every
studio shares the one login). The owner signs in there once; it is kept for the next run.
Keeping the sign-in: Apple's and Google's sign-in cookies are partly session cookies. storebound launches the copy
and the own profile with `--restore-last-session` (so Chromium loads them again) and always closes the browser
gracefully (window closed or Ctrl+C), so they are written to disk. Apple keeps a long sign-in only with "Keep me
signed in" ticked and "Trust" chosen for the browser; Google with "Stay signed in"; storebound reminds the owner on
the sign-in page. In copy mode the owner's Chrome Cookies file holds the session cookies only while that Chrome
runs, or when Chrome's "On startup: Continue where you left off" is on; otherwise one sign-in in the window is
needed.
or `--cdp http://127.0.0.1:<port>`): storebound opens one new tab there and closes only that tab. That is the
owner's own browser in place: no cookie leaves it.
- **copy: opt-in only, NOT recommended for Google** (`--use-chrome-session google|apple`, macOS): a temporary copy
of ONLY `Local State` and the profile's `Cookies` (+ journal), filtered to the ONE store's sign-in cookies
(google: play.google.com / accounts.google.com; apple: apple.com hosts), opened in real Google Chrome and deleted
when the run ends. Never copied: passwords, history, extensions, anything else. `--chrome-profile "Profile 1"`
picks the Chrome profile. **Why opt-in:** Google binds its sign-in cookies to the device (Chrome's Device Bound
Session Credentials: a key in the TPM / Secure Enclave that never leaves the machine; Google only renews the
short-lived cookies for the browser that holds it; public on Windows from Chrome 146, rolling out on macOS from
Chrome 147), and Google signs out sessions whose cookies look stolen: "the user is logged out of their account for
that session and any related suspicious sessions on that device" and must verify again. A copy of the owner's
Google cookies in a second Chrome is what that detection looks for: Google may answer `/signin/rejected`, AND it
may sign the owner out of their real Chrome or flag the account. In real use (October 2026) a copied Google
session reached Play Console once, then landed on `/v3/signin/rejected` and the account chooser. Apple publishes
no device binding for its web sign-in (fastlane's `spaceauth` reuses Apple sessions on other machines; Apple ties
them to a region), but it does not rule out a security check either, so the Apple copy is opt-in too. An agent
never adds `--use-chrome-session` on its own; the owner chooses it after reading this.
Sources: https://blog.google/security/protecting-cookies-with-device-bound-session-credentials/ ,
https://developer.chrome.com/docs/web-platform/device-bound-session-credentials ,
https://knowledge.workspace.google.com/admin/security/investigate-and-take-action-on-suspicious-session-cookies ,
https://docs.fastlane.tools/getting-started/ios/authentication/
`--fresh-profile` still works: it skips attach and copy.
Keeping the sign-in: Apple's and Google's sign-in cookies are partly session cookies. storebound starts Chrome with
`--restore-last-session` (so Chromium loads them again) and always closes it gracefully (CDP `Browser.close`,
window closed or Ctrl+C), so they are written to disk. Apple keeps a long sign-in only with "Keep me signed in"
ticked and "Trust" chosen for the browser; Google with "Stay signed in"; storebound reminds the owner on the
sign-in page.
Sign-in is detected by URL and by frame (App Store Connect `/login`, a frame from idmsa.apple.com /
appleid.apple.com, accounts.google.com, Google `/signin`), by a visible password field in any frame, and by the
form's own texts ("Sign in with Passkey", "Email or Phone Number", "Keep me signed in" and the Turkish ones):

View file

@ -150,8 +150,8 @@ Each entry: **symptom** → cause → what to do → what guards it now. The ful
while the owner is signed in; an older detector counted any frame on a sign-in host as a sign-in page.
- **Do:** read the line "sign-in detected because: <rule> at <url>" and the heartbeats (they name the page). A
top-level `accounts.google.com` / `idmsa.apple.com` / App Store Connect `/login` URL right after the launch means the
copied Chrome session did not carry over (expired or tied to that device): sign in once in the window, or run with
`--fresh-profile`. Anything else: re-run with `--debug-dump` (up to 3 dumps of the sign-in wait) and attach the
copied Chrome session did not carry over (expired or tied to that device): sign in once in the window, or run
without `--use-chrome-session` (pitfall 18). Anything else: re-run with `--debug-dump` (up to 3 dumps of the sign-in wait) and attach the
folder after the owner looked at `screen.jpg`.
- **Guard:** `signInReason` in `cli/console/fill.ts` (top-level URL, the main frame's password / email-entry form, a
visible Apple sign-in frame; no other frame counts), the sign-in watchdog dump, `tests/console-fill.test.ts`.
@ -167,6 +167,29 @@ Each entry: **symptom** → cause → what to do → what guards it now. The ful
- **Guard:** `setUpAllowed` (`cli/lib/console-forms.ts`), `setUpClick` / `setUpType` (`cli/console/fill.ts`),
`tests/console-flow.test.ts` with `tests/fixtures/console/apple-app-privacy-setup.html`.
## 18. Never copy Google session cookies: they are device-bound
- **Symptom:** `console fill play-app-record` with the owner's copied Chrome cookies reached the Play account chooser
once (22:32); between runs Google showed `accounts.google.com/v3/signin/rejected`; from 23:12 the same copy landed on
the account chooser.
- **Cause:** Google binds its session cookies to the device. Chrome's Device Bound Session Credentials keep a key in
the TPM / Secure Enclave that cannot leave the machine; Google only renews the short-lived cookies for the browser
that proves it holds that key (public on Windows from Chrome 146, rolling out on macOS from Chrome 147). Google also
signs out sessions whose cookies look stolen ("logged out of their account for that session and any related
suspicious sessions on that device", then an extra verification). Copied cookies in a second Chrome look exactly
like cookie theft: the copy is refused, and the owner's REAL Chrome session can be signed out or the account asked
to verify again.
- **Do:** never copy Google cookies. The default is storebound's own profile (`~/.storebound/console-profile/<store>`):
the owner signs in there once, by hand; it is kept. `--use-chrome-session google` exists only as an explicit owner
opt-in that prints the warning; an agent never adds it. Apple publishes no such binding, but `--use-chrome-session
apple` is opt-in too. If Google says "Couldn't sign you in" in storebound's window, storebound prints "Google refused
sign-in in this window (/signin/rejected)": try again, or sign in at accounts.google.com first in that window.
- **Guard:** `browserPlan` / `openConsoleBrowser` in `cli/console/session.ts` (no copy without the flag, the copy keeps
only the form's own provider), Chrome started by storebound without `--enable-automation` and with
`navigator.webdriver` false, `googleRejected`; `tests/console-session.test.ts`.
- **Sources:** https://blog.google/security/protecting-cookies-with-device-bound-session-credentials/ ,
https://developer.chrome.com/docs/web-platform/device-bound-session-credentials ,
https://knowledge.workspace.google.com/admin/security/investigate-and-take-action-on-suspicious-session-cookies
## Adding a lesson
Every bug found in real use gets an entry in `docs/pitfalls.md` and here, and, where possible, a check or a test
that fails without the fix (see CONTRIBUTING.md).

View file

@ -8,7 +8,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync
import { hostname, tmpdir } from 'node:os';
import { join } from 'node:path';
import { openConsoleBrowser } from '../cli/console/session';
import { installedBrowsers, launch, launchCandidates, launchPersistent, profileHolder } from '../cli/shots/browser';
import { installedBrowsers, launch, launchCandidates, launchPersistent, persistentExecutable, profileHolder } from '../cli/shots/browser';
/* eslint-disable @typescript-eslint/no-explicit-any */
@ -123,21 +123,19 @@ describe('profile held by another browser', () => {
});
describe('console fill', () => {
test('the persistent launch gets executablePath = STOREBOUND_CHROME when it is set', async () => {
const exe = join(tmp, 'Chrome for Testing');
writeFileSync(exe, '');
const before = process.env.STOREBOUND_CHROME;
process.env.STOREBOUND_CHROME = exe;
try {
const f = fakeCache('sb-chrome', ['chromium_headless_shell-1187'], { chrome: true });
const b = await openConsoleBrowser(tmp, { store: 'apple', fresh: true, profileDir: join(tmp, 'sb-profile'), say: () => {}, launchDeps: { pw: f.pw } });
expect(b.mode).toBe('profile');
expect(f.calls).toHaveLength(1);
expect(f.calls[0].executablePath).toBe(exe);
expect(f.calls[0].channel).toBeUndefined();
expect(f.calls[0].args).toContain('--restore-last-session');
} finally {
if (before === undefined) delete process.env.STOREBOUND_CHROME; else process.env.STOREBOUND_CHROME = before;
}
test('the window starts STOREBOUND_CHROME first, then Google Chrome, then full Chromium; the copy only Google Chrome', () => {
const i = { storeboundChrome: '/x/Chrome for Testing', chrome: CHROME, chromium: '/c' };
expect(persistentExecutable('visible', i).path).toBe('/x/Chrome for Testing');
expect(persistentExecutable('visible', { ...i, storeboundChrome: null }).path).toBe(CHROME);
expect(persistentExecutable('visible', { storeboundChrome: null, chrome: null, chromium: '/c' }).path).toBe('/c');
expect(persistentExecutable('chrome', i).path).toBe(CHROME);
expect(() => persistentExecutable('chrome', { ...i, chrome: null })).toThrow('Install Google Chrome.');
expect(() => persistentExecutable('visible', { storeboundChrome: null, chrome: null, chromium: null }, { STOREBOUND_CHROME: '/gone' })).toThrow('STOREBOUND_CHROME /gone: no such file');
});
test('openConsoleBrowser with nothing installed: the resolver error, nothing launched', async () => {
const f = fakeCache('none', []);
const e = await openConsoleBrowser(tmp, { store: 'apple', fresh: true, profileDir: join(tmp, 'sb-profile'), say: () => {}, launchDeps: { pw: f.pw, installed: { storeboundChrome: null, chrome: null, chromium: null } } }).catch(x => x as Error);
expect((e as Error).message).toContain('no usable browser');
expect(f.calls).toEqual([]);
});
});

View file

@ -1047,7 +1047,7 @@ describe.skipIf(!browser)('sign-in detection: only the top-level URL, the main f
await log.wait('four heartbeats', l => l.filter(m => m.startsWith('still waiting for the sign-in')).length >= 4);
expect(said).toContain('sign-in detected because: the top-level URL is a sign-in page at https://accounts.google.com/v3/signin/identifier');
expect(said.filter(m => m.startsWith('sign-in detected because'))).toHaveLength(1);
expect(said).toContain('your Chrome session for Play Console did not carry over (expired or tied to this device); sign in once in this window, or run with --fresh-profile');
expect(said).toContain('your copied Chrome session for Play Console did not carry over (expired or tied to your own Chrome); sign in once in this window, or run without --use-chrome-session');
expect(said.find(m => m.startsWith('still waiting for the sign-in'))).toMatch(/\(https:\/\/accounts\.google\.com\/v3\/signin\/identifier: a sign-in page \(the top-level URL is a sign-in page\)\)$/);
expect(said.find(m => m.startsWith('the sign-in has not finished after'))).toContain(`Debug saved to ${dir}`);
const names = readdirSync(dir).sort();

View file

@ -1,13 +1,16 @@
// storebound console fill: which browser it uses. Pure parts (data dir, profile pick, file selection, cookie host
// filter, DevToolsActivePort) and the session copy on throwaway folders (cleanup on success and on error). The
// integration test sets a cookie on a local fixture site in a THROWAWAY Chrome profile, then opens the console browser
// in copy mode from that profile and sees the cookie (and not the one of another host). It never touches the real
// Chrome profile or a store site; it skips when Google Chrome is not installed or not on macOS.
// filter, DevToolsActivePort, the opt-in flag, the browser plan, Google's rejected page, the launch switches) and the
// session copy on throwaway folders (cleanup on success and on error). Safety: the default run never copies Google
// (or Apple) cookies; the opt-in copy of Google cookies warns about device-bound sessions. Integration (real Google
// Chrome, throwaway profiles, a local fixture site): the window storebound starts has navigator.webdriver false and no
// automation switch on its real command line (chrome://version); the opt-in copy carries a cookie of the source
// profile and drops another host's. It never touches the real Chrome profile or a store site; it skips when Google
// Chrome is not installed or not on macOS.
import { afterAll, describe, expect, test } from 'bun:test';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { chromeDataDir, CONSOLE_HOSTS, cookieApplies, copySession, devToolsEndpoint, filterCookies, NEVER_COPY, openConsoleBrowser, pickProfile, sessionFiles } from '../cli/console/session';
import { APPLE_COPY_NOTE, AUTOMATION_SWITCHES, browserPlan, chromeDataDir, consoleChromeArgs, CONSOLE_HOSTS, cookieApplies, copySession, devToolsEndpoint, filterCookies, GOOGLE_COPY_WARNING, GOOGLE_REJECTED, googleRejected, NEVER_COPY, openConsoleBrowser, parseUseChromeSession, pickProfile, PROVIDER_HOSTS, sessionFiles } from '../cli/console/session';
import { capped, CLOSE_TIMEOUT_MS, LAUNCH_TIMEOUT_MS } from './helpers/browser';
const scratch = mkdtempSync(join(tmpdir(), 'sb-session-test-'));
@ -72,6 +75,67 @@ describe('pure parts', () => {
});
});
describe('Google session safety (device-bound cookies)', () => {
const none = { storeboundChrome: null, chrome: null, chromium: null };
const copies = () => readdirSync(tmpdir()).filter(f => f.startsWith('storebound-chrome-')).length;
test('--use-chrome-session: google | apple only; absent = no copy', () => {
expect(parseUseChromeSession(undefined)).toEqual([]);
expect(parseUseChromeSession('google')).toEqual(['google']);
expect(parseUseChromeSession('Apple, google,apple')).toEqual(['apple', 'google']);
expect(() => parseUseChromeSession(true)).toThrow('--use-chrome-session');
expect(() => parseUseChromeSession('all')).toThrow('--use-chrome-session');
});
test('plan: no copy by default for either store; an opt-in copies only the form\'s own provider', () => {
for (const store of ['play', 'apple'] as const) expect(browserPlan({ store, platform: 'darwin' }, true).copy).toBeNull();
expect(browserPlan({ store: 'play', platform: 'darwin', useChromeSession: ['google'] }, true).copy).toBe('google');
expect(browserPlan({ store: 'play', platform: 'darwin', useChromeSession: ['apple'] }, true).copy).toBeNull();
expect(browserPlan({ store: 'apple', platform: 'darwin', useChromeSession: ['google'] }, true).copy).toBeNull();
expect(browserPlan({ store: 'apple', platform: 'darwin', useChromeSession: ['apple'] }, true).copy).toBe('apple');
expect(browserPlan({ store: 'play', platform: 'darwin', useChromeSession: ['google'], fresh: true }, true).copy).toBeNull();
expect(browserPlan({ store: 'play', platform: 'linux', useChromeSession: ['google'] }, true).copy).toBeNull();
// an Apple copy never carries a Google cookie and the other way round
expect(PROVIDER_HOSTS.apple.some(h => h.includes('google'))).toBe(false);
expect(PROVIDER_HOSTS.google.some(h => h.includes('apple'))).toBe(false);
});
test('the default run makes no copy of the owner\'s Chrome cookies (play and apple)', async () => {
const src = await fakeChrome('chrome-default');
for (const store of ['play', 'apple'] as const) {
const said: string[] = [];
const before = copies();
const e = await openConsoleBrowser(scratch, { store, profileDir: tmp(`own-${store}`), say: m => said.push(m), chromeDir: src, platform: 'darwin', launchDeps: { installed: none } }).catch(x => x as Error);
expect((e as Error).message).toContain('no usable browser'); // it went straight to storebound's own profile
expect(said.join('\n')).not.toMatch(/copied|Chrome sign-in|WARNING/);
expect(copies()).toBe(before);
}
});
test('the opt-in Google copy warns about device-bound sessions first; the Apple one says its note', async () => {
const src = await fakeChrome('chrome-optin');
const said: string[] = [];
await openConsoleBrowser(scratch, { store: 'play', useChromeSession: ['google'], profileDir: tmp('own-optin'), say: m => said.push(m), chromeDir: src, platform: 'darwin', launchDeps: { installed: none } }).catch(() => null);
expect(said[0]).toBe(GOOGLE_COPY_WARNING);
expect(GOOGLE_COPY_WARNING).toContain('sign you out of your real Chrome');
expect(said.join('\n')).toContain('could not use your Chrome sign-in'); // no Google Chrome here: the copy is removed again
const saidA: string[] = [];
await openConsoleBrowser(scratch, { store: 'apple', useChromeSession: ['apple'], profileDir: tmp('own-optin-a'), say: m => saidA.push(m), chromeDir: src, platform: 'darwin', launchDeps: { installed: none } }).catch(() => null);
expect(saidA[0]).toBe(APPLE_COPY_NOTE);
});
test('Google\'s rejected page is recognised (and only that page)', () => {
for (const u of ['https://accounts.google.com/v3/signin/rejected?continue=x', 'https://accounts.google.com/signin/rejected', 'https://accounts.google.com/signin/v2/rejected']) expect(googleRejected(u)).toBe(true);
for (const u of ['https://accounts.google.com/v3/signin/accountchooser', 'https://accounts.google.com/v3/signin/identifier', 'https://play.google.com/console/signin/rejected', 'https://example.com/v3/signin/rejected', 'not a url']) expect(googleRejected(u)).toBe(false);
expect(GOOGLE_REJECTED).toBe('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.');
});
test('launch switches: no automation switch, webdriver off, session restore, a DevTools port the OS picks', () => {
const a = consoleChromeArgs('/p');
for (const s of AUTOMATION_SWITCHES) expect(a.some(x => x.startsWith(s))).toBe(false);
expect(a).toContain('--disable-blink-features=AutomationControlled');
expect(a).toContain('--restore-last-session');
expect(a).toContain('--remote-debugging-port=0');
expect(a).toContain('--user-data-dir=/p');
expect(a.some(x => /user-agent/i.test(x))).toBe(false);
expect(consoleChromeArgs('/p', { headless: true })).toContain('--headless=new');
});
});
describe('session copy', () => {
test('copies only the session files, filters the cookies, cleans up', async () => {
const src = await fakeChrome('chrome-a');
@ -151,9 +215,10 @@ describe.skipIf(!hasChrome)('copy mode with real Chrome (throwaway profile, loca
seed = null;
const said: string[] = [];
b = await openConsoleBrowser(join(import.meta.dir, '..'), {
store: 'apple', profileDir: tmp('own-profile'), say: m => said.push(m), chromeDir: src, hosts: ['127.0.0.1'], headless: true, platform: 'darwin', launchTimeoutMs: LAUNCH_TIMEOUT_MS,
store: 'apple', useChromeSession: ['apple'], profileDir: tmp('own-profile'), say: m => said.push(m), chromeDir: src, hosts: ['127.0.0.1'], headless: true, platform: 'darwin', launchTimeoutMs: LAUNCH_TIMEOUT_MS,
});
expect(b.mode).toBe('copy');
expect(said[0]).toBe(APPLE_COPY_NOTE);
expect(said.join('\n')).toContain('your Chrome sign-in');
expect(said.join('\n')).not.toContain('sb_test');
expect(b.tempDir && existsSync(b.tempDir)).toBe(true);
@ -161,13 +226,45 @@ describe.skipIf(!hasChrome)('copy mode with real Chrome (throwaway profile, loca
expect(await b.page.textContent('body')).toBe('cookie: yes');
await b.page.goto(`${other}/check`);
expect(await b.page.textContent('body')).toBe('cookie: no');
await b.context.close();
await b.close();
await b.closed;
expect(existsSync(b.tempDir!)).toBe(false);
} finally {
if (seed) await capped(seed.close(), CLOSE_TIMEOUT_MS);
if (b && !b.isClosed()) { await capped(b.context.close(), CLOSE_TIMEOUT_MS); b.cleanup(); }
if (b && !b.isClosed()) { await capped(b.close(), CLOSE_TIMEOUT_MS); b.cleanup(); }
server.stop(true);
}
}, 2 * LAUNCH_TIMEOUT_MS); // bounded by the two launches; ~3 s on an idle machine
});
describe.skipIf(!hasChrome)('the console window has no automation markers (real Chrome, throwaway profile, local page)', () => {
test('navigator.webdriver is false and the real command line has no --enable-automation', async () => {
const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response(
'<p id="r"></p><script>document.getElementById("r").textContent = JSON.stringify({ webdriver: navigator.webdriver, ua: navigator.userAgent })</script>',
{ headers: { 'content-type': 'text/html' } }) });
let b: any = null;
try {
b = await openConsoleBrowser(join(import.meta.dir, '..'), {
store: 'play', profileDir: tmp('own-webdriver'), say: () => {}, headless: true, platform: 'darwin', chromeDir: tmp('no-chrome-here'), launchTimeoutMs: LAUNCH_TIMEOUT_MS,
});
expect(b.mode).toBe('profile');
for (const s of AUTOMATION_SWITCHES) expect(b.args.some((x: string) => x.startsWith(s))).toBe(false);
await b.page.goto(`http://127.0.0.1:${server.port}/`);
const r = JSON.parse(await b.page.textContent('#r'));
expect(r.webdriver).toBe(false);
expect(r.ua).toContain('Chrome/');
await b.page.goto('chrome://version');
const cmd: string = await b.page.textContent('#command_line');
expect(cmd).toContain('--user-data-dir=');
expect(cmd).not.toContain('--enable-automation');
expect(cmd).not.toContain('--remote-debugging-pipe');
expect(cmd).toContain('--disable-blink-features=AutomationControlled');
await b.close();
await b.closed;
expect(b.isClosed()).toBe(true);
} finally {
if (b && !b.isClosed()) await capped(b.close(), CLOSE_TIMEOUT_MS);
server.stop(true);
}
}, 2 * LAUNCH_TIMEOUT_MS);
});