@trusty-squire/mcp 1.0.47 → 1.0.48-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,7 @@ export interface LoginResult {
5
5
  detail?: string;
6
6
  }
7
7
  export declare function contextHasProviderSession(context: BrowserContext, provider: OAuthProviderId): Promise<boolean>;
8
+ export declare function profileHasProviderCookies(profileDir: string, provider: OAuthProviderId): boolean;
8
9
  export declare function detectActiveProviderSessions(profileDir?: string): Promise<OAuthProviderId[]>;
9
10
  export type GoogleAuthState = "consent" | "needs_login" | "challenge" | "not_google";
10
11
  export declare function classifyGoogleAuthState(url: string, bodyText: string): GoogleAuthState;
@@ -26,6 +27,9 @@ export interface RunInBotChromeOpts {
26
27
  onSuccess?: (context: BrowserContext) => Promise<void>;
27
28
  apiBaseUrl?: string;
28
29
  heartbeatMessage?: string | (() => string);
30
+ plainProfileLogin?: boolean;
31
+ plainPollUntilDone?: (profileDir: string) => Promise<boolean>;
32
+ plainOnSuccess?: (profileDir: string) => Promise<void>;
29
33
  }
30
34
  export declare function runInBotChrome(opts: RunInBotChromeOpts): Promise<{
31
35
  status: "completed" | "preflight_satisfied" | "timeout";
@@ -41,7 +45,7 @@ export declare function ensureOAuthSession(opts?: {
41
45
  export declare function extractGoogleAccountEmail(pageText: string): string | null;
42
46
  export declare function openInstallConfirmInBotChrome(opts: {
43
47
  confirmUrl: string;
44
- pollUntilClaimed: (context: BrowserContext) => Promise<boolean>;
48
+ pollUntilClaimed: (profileDir: string) => Promise<boolean>;
45
49
  profileDir?: string;
46
50
  timeoutMinutes?: number;
47
51
  apiBaseUrl?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"google-login.d.ts","sourceRoot":"","sources":["../../src/bot/google-login.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"google-login.d.ts","sourceRoot":"","sources":["../../src/bot/google-login.ts"],"names":[],"mappings":"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"}
@@ -32,7 +32,8 @@ import boxen from "boxen";
32
32
  import chalk from "chalk";
33
33
  import { shortenVncUrl } from "../api-client.js";
34
34
  import { CHROME_PROFILE_DIR, launchWithProfileGate, ProfileBusyError, waitForProfileFree } from "./profile.js";
35
- import { markProviderLoggedIn, recordProviderEmail } from "./login-state.js";
35
+ import { launchPlainLoginBrowser, launchSelfManagedLoginContext, resolveChannelBinary, selfLaunchEnabled, } from "./browser.js";
36
+ import { markProviderLoggedIn } from "./login-state.js";
36
37
  import { randomBytes } from "node:crypto";
37
38
  const require = createRequire(import.meta.url);
38
39
  // Route the INTERACTIVE login through the same proxy the bot uses, so the
@@ -123,6 +124,43 @@ async function hasProviderSession(context, target) {
123
124
  export async function contextHasProviderSession(context, provider) {
124
125
  return hasProviderSession(context, LOGIN_TARGETS[provider]);
125
126
  }
127
+ // Provider session cookie NAMES long/specific enough to detect by substring in
128
+ // the raw on-disk Cookies DB. Bare short names (Google's "SID") are omitted —
129
+ // they substring-collide with other cookie names.
130
+ const PROVIDER_COOKIE_MARKERS = {
131
+ google: ["__Secure-1PSID", "SAPISID"],
132
+ github: ["user_session"],
133
+ };
134
+ // Read a PLAIN login browser's provider session straight off disk. The connect
135
+ // claim's login browser never attaches CDP (see launchPlainLoginBrowser — a
136
+ // CDP attach fails Google's OAuth "secure browser" check), so there is no live
137
+ // context to query. Chrome stores cookie NAMES as plaintext in the Cookies
138
+ // SQLite b-tree pages, so a raw byte search reliably answers "did this
139
+ // session's cookie get written" — dependency-free and Node-version-agnostic
140
+ // (engines >=20; node:sqlite is 22.5+). Presence-only; never decrypts values.
141
+ export function profileHasProviderCookies(profileDir, provider) {
142
+ const markers = PROVIDER_COOKIE_MARKERS[provider];
143
+ // <profile>/Default/Cookies is the norm; a profile that never opened a window
144
+ // may only have <profile>/Cookies. Include the WAL sidecar — a just-written
145
+ // cookie may not be checkpointed into the main file yet.
146
+ const bases = [join(profileDir, "Default", "Cookies"), join(profileDir, "Cookies")];
147
+ for (const base of bases) {
148
+ for (const path of [base, `${base}-wal`]) {
149
+ if (!existsSync(path))
150
+ continue;
151
+ let text;
152
+ try {
153
+ text = readFileSync(path).toString("latin1");
154
+ }
155
+ catch {
156
+ continue;
157
+ }
158
+ if (markers.some((m) => text.includes(m)))
159
+ return true;
160
+ }
161
+ }
162
+ return false;
163
+ }
126
164
  // VALIDATE a session instead of just spotting a cookie. A provider session that
127
165
  // expired server-side often leaves its `user_session` cookie sitting in the
128
166
  // profile, so name-presence false-positives ("the GitHub marker lies"). Here we
@@ -625,6 +663,45 @@ export async function runInBotChrome(opts) {
625
663
  return await runHeadlessChrome(opts);
626
664
  }
627
665
  async function runDisplayedChrome(opts) {
666
+ // PLAIN-BROWSER path (connect claim): launch plain Chrome, never attach CDP,
667
+ // detect completion off the API + on-disk cookie store. See
668
+ // launchPlainLoginBrowser / RunInBotChromeOpts.plainProfileLogin.
669
+ if (opts.plainProfileLogin === true) {
670
+ if (opts.plainPollUntilDone === undefined) {
671
+ throw new Error("plainProfileLogin set without plainPollUntilDone");
672
+ }
673
+ const binary = resolveChannelBinary("chrome");
674
+ if (binary === null) {
675
+ throw new Error("no Chrome binary found for the plain login browser");
676
+ }
677
+ const proxyOpt = loginProxyOption();
678
+ const browser = await launchPlainLoginBrowser({
679
+ binary,
680
+ profileDir: opts.profileDir,
681
+ url: opts.url,
682
+ window: { width: 1280, height: 800 },
683
+ env: process.env,
684
+ // Self-launch/--proxy-server can't carry proxy auth — drop a credentialed
685
+ // proxy (direct). Connect from the box is the point anyway.
686
+ proxyServer: proxyOpt !== undefined && proxyOpt.password === undefined ? proxyOpt.server : null,
687
+ extraArgs: ["--no-sandbox", "--disable-dev-shm-usage"],
688
+ });
689
+ try {
690
+ console.error(`\n[login] A Chrome window has opened. ${opts.bannerLabel}\n`);
691
+ console.error(`[connect-debug] launcher=plain_display chrome=${binary}`);
692
+ const ok = await pollUntil(opts.deadline, () => opts.plainPollUntilDone(opts.profileDir), opts.heartbeatMessage);
693
+ if (ok && opts.plainOnSuccess !== undefined) {
694
+ try {
695
+ await opts.plainOnSuccess(opts.profileDir);
696
+ }
697
+ catch { /* swallow */ }
698
+ }
699
+ return { status: ok ? "completed" : "timeout" };
700
+ }
701
+ finally {
702
+ await browser.teardown();
703
+ }
704
+ }
628
705
  const chromium = resolveChromium();
629
706
  const context = await launchWithProfileGate(opts.profileDir, () => chromium.launchPersistentContext(opts.profileDir, {
630
707
  channel: "chrome",
@@ -696,6 +773,11 @@ async function runHeadlessChrome(opts) {
696
773
  // Playwright handle, closed via context.close(). Tracked here so the
697
774
  // signal handler can release the profile lock before exiting.
698
775
  let activeContext;
776
+ // Teardown for the login browser — for the self-launch path this ALSO kills
777
+ // the spawned Chrome child (a bare context.close() over CDP leaves it
778
+ // running), for the persistent fallback it's just context.close(). Tracked
779
+ // so the signal handler can release the profile lock before exiting.
780
+ let activeTeardown;
699
781
  // Ensure nothing is orphaned if the process dies mid-flow. `exit`
700
782
  // covers a normal return; SIGTERM/SIGINT cover an interrupted run —
701
783
  // once a signal listener is registered the default terminate is
@@ -706,13 +788,13 @@ async function runHeadlessChrome(opts) {
706
788
  teardown(rig);
707
789
  process.exit(130);
708
790
  };
709
- if (activeContext !== undefined) {
710
- // Close the browser to release the persistent-profile lock — but
711
- // cap the wait: a wedged Chrome under Xvfb can hang close()
712
- // indefinitely, and the rig MUST still be torn down. Whichever
713
- // wins (clean close, or the 3s cap), `finish` runs.
791
+ if (activeTeardown !== undefined) {
792
+ // Tear down the browser to release the persistent-profile lock (and
793
+ // kill the self-launched Chrome) — but cap the wait: a wedged Chrome
794
+ // under Xvfb can hang indefinitely, and the rig MUST still be torn
795
+ // down. Whichever wins (clean teardown, or the 3s cap), `finish` runs.
714
796
  const capped = new Promise((r) => setTimeout(r, 3000));
715
- Promise.race([activeContext.close().catch(() => undefined), capped]).then(finish, finish);
797
+ Promise.race([activeTeardown().catch(() => undefined), capped]).then(finish, finish);
716
798
  }
717
799
  else {
718
800
  finish();
@@ -726,41 +808,115 @@ async function runHeadlessChrome(opts) {
726
808
  rig.procs.push(spawnBg("Xvfb", [display, "-screen", "0", `${HEADLESS_W}x${HEADLESS_H}x24`, "-ac"]));
727
809
  await new Promise((r) => setTimeout(r, 1500));
728
810
  // 2. Chrome on that display, persistent profile, window filling the display.
729
- const chromium = resolveChromium();
730
- const context = await launchWithProfileGate(opts.profileDir, () => chromium.launchPersistentContext(opts.profileDir, {
731
- channel: "chrome",
732
- headless: false,
733
- viewport: null, // use the real window size
734
- env: { ...process.env, DISPLAY: display },
735
- // Drop --enable-automation: kills the "controlled by automated test
736
- // software" infobar and the matching automation fingerprint.
737
- ignoreDefaultArgs: ["--enable-automation"],
738
- ...(loginProxyOption() !== undefined ? { proxy: loginProxyOption() } : {}),
739
- args: [
740
- `--window-position=0,0`,
741
- `--window-size=${HEADLESS_W},${HEADLESS_H}`,
742
- // App mode strips the tabs, URL bar, and the "unsupported
743
- // command-line flag" warning that otherwise eat the top
744
- // third of a phone-shaped framebuffer when viewed through
745
- // noVNC. The install page gets the full window.
746
- `--app=${opts.url}`,
747
- "--disable-blink-features=AutomationControlled",
748
- // Suppresses the "You are using an unsupported command-line
749
- // flag: --no-sandbox" yellow infobar that otherwise eats the
750
- // top strip of the framebuffer once we hand it to noVNC.
751
- // Standard automation-test flag; doesn't affect OAuth/cookies.
752
- "--test-type",
753
- "--no-sandbox",
754
- "--disable-dev-shm-usage",
755
- ],
756
- }));
811
+ // Self-launch + connectOverCDP is the STATE.md 2026-06-12 launcher fix:
812
+ // a launchPersistentContext-driven Chrome carries the launch-time
813
+ // instrumentation anti-bot (Cloudflare Turnstile, and — the bug this
814
+ // ports the fix for — Google's sign-in device-prompt) reads as a bot,
815
+ // while a self-launched Chrome attached over CDP does not. The signup
816
+ // path (BrowserController) already migrated; the connect login was the
817
+ // last consumer of the detectable launcher. Fall back to the old
818
+ // launcher when self-launch is opted out (BOT_SELF_LAUNCH=0), Chrome
819
+ // isn't resolvable as a real binary, or a credentialed proxy is set
820
+ // (self-launch's --proxy-server can't carry proxy auth).
821
+ const proxyOpt = loginProxyOption();
822
+ const chromeBinary = resolveChannelBinary("chrome");
823
+ const useSelfLaunch = selfLaunchEnabled() &&
824
+ chromeBinary !== null &&
825
+ (proxyOpt === undefined || proxyOpt.password === undefined);
826
+ const sharedChromeArgs = [
827
+ "--disable-blink-features=AutomationControlled",
828
+ // --test-type suppresses the "unsupported command-line flag:
829
+ // --no-sandbox" yellow infobar that otherwise eats the top strip of the
830
+ // phone-shaped framebuffer once handed to noVNC. Standard automation
831
+ // flag; doesn't affect OAuth/cookies.
832
+ "--test-type",
833
+ "--no-sandbox",
834
+ "--disable-dev-shm-usage",
835
+ ];
836
+ // PLAIN-BROWSER mode (connect claim): NO CDP at all — a CDP-attached Chrome
837
+ // fails Google's OAuth "secure browser" check (STATE.md 2026-07-20, fully
838
+ // bisected). `context` stays undefined; completion is read via
839
+ // plainPollUntilDone (API + on-disk cookies), never a live context.
840
+ const plain = opts.plainProfileLogin === true;
841
+ let context;
842
+ let teardownContext;
843
+ let launcherMode;
844
+ if (plain) {
845
+ if (opts.plainPollUntilDone === undefined) {
846
+ throw new Error("plainProfileLogin set without plainPollUntilDone");
847
+ }
848
+ if (chromeBinary === null) {
849
+ throw new Error("no Chrome binary found for the plain login browser");
850
+ }
851
+ launcherMode = "plain";
852
+ const browser = await launchPlainLoginBrowser({
853
+ binary: chromeBinary,
854
+ profileDir: opts.profileDir,
855
+ url: opts.url,
856
+ window: { width: HEADLESS_W, height: HEADLESS_H },
857
+ env: { ...process.env, DISPLAY: display },
858
+ proxyServer: proxyOpt !== undefined && proxyOpt.password === undefined ? proxyOpt.server : null,
859
+ extraArgs: sharedChromeArgs,
860
+ });
861
+ teardownContext = browser.teardown;
862
+ }
863
+ else if (useSelfLaunch && chromeBinary !== null) {
864
+ launcherMode = "self_launch";
865
+ const launched = await launchSelfManagedLoginContext({
866
+ binary: chromeBinary,
867
+ profileDir: opts.profileDir,
868
+ // App mode (--app) strips tabs/URL bar so the install page gets the
869
+ // full phone-shaped framebuffer under noVNC.
870
+ initialUrl: opts.url,
871
+ appMode: true,
872
+ window: { width: HEADLESS_W, height: HEADLESS_H },
873
+ env: { ...process.env, DISPLAY: display },
874
+ proxyServer: proxyOpt?.server ?? null,
875
+ extraArgs: sharedChromeArgs,
876
+ });
877
+ context = launched.context;
878
+ teardownContext = launched.teardown;
879
+ }
880
+ else {
881
+ launcherMode = "persistent";
882
+ const chromium = resolveChromium();
883
+ const persistent = await launchWithProfileGate(opts.profileDir, () => chromium.launchPersistentContext(opts.profileDir, {
884
+ channel: "chrome",
885
+ headless: false,
886
+ viewport: null, // use the real window size
887
+ env: { ...process.env, DISPLAY: display },
888
+ // Drop --enable-automation: kills the "controlled by automated test
889
+ // software" infobar and the matching automation fingerprint.
890
+ ignoreDefaultArgs: ["--enable-automation"],
891
+ ...(proxyOpt !== undefined ? { proxy: proxyOpt } : {}),
892
+ args: [
893
+ `--window-position=0,0`,
894
+ `--window-size=${HEADLESS_W},${HEADLESS_H}`,
895
+ `--app=${opts.url}`,
896
+ ...sharedChromeArgs,
897
+ ],
898
+ }));
899
+ context = persistent;
900
+ teardownContext = async () => {
901
+ await persistent.close().catch(() => undefined);
902
+ };
903
+ }
757
904
  activeContext = context;
905
+ activeTeardown = teardownContext;
906
+ // Trace which launcher ran — survives the noVNC collapsing (stderr). The
907
+ // self-launch port is the fix under test for the Google device-prompt
908
+ // rejection; this line is how we confirm which path a real run took.
909
+ console.error(`[connect-debug] launcher=${launcherMode} chrome=${chromeBinary ?? "<none>"} display=${display}`);
758
910
  try {
759
- if (opts.preflight !== undefined && await opts.preflight(context)) {
760
- return { status: "preflight_satisfied" };
911
+ // CDP path only: preflight + drive the first page to the URL. The plain
912
+ // path has no context — plain Chrome's --app already opened opts.url.
913
+ if (context !== undefined) {
914
+ if (opts.preflight !== undefined && await opts.preflight(context)) {
915
+ return { status: "preflight_satisfied" };
916
+ }
917
+ const page = context.pages()[0] ?? (await context.newPage());
918
+ await page.goto(opts.url, { waitUntil: "domcontentloaded" });
761
919
  }
762
- const page = context.pages()[0] ?? (await context.newPage());
763
- await page.goto(opts.url, { waitUntil: "domcontentloaded" });
764
920
  // 3. x11vnc on the display — localhost-only, password-gated, -noshm
765
921
  // (the box's X server lacks the shared-memory extension).
766
922
  // G14 — pass the password via a 0600 file, NOT `-passwd <plain>`
@@ -840,20 +996,36 @@ async function runHeadlessChrome(opts) {
840
996
  // set — opening a second context after teardown is racy and
841
997
  // silently fails on profile lock), then tear the whole stack
842
998
  // down.
843
- const ok = await pollUntil(opts.deadline, () => opts.pollUntilDone(context), opts.heartbeatMessage);
844
- if (ok && opts.onSuccess !== undefined) {
845
- try {
846
- await opts.onSuccess(context);
999
+ const ok = await pollUntil(opts.deadline, () => context !== undefined
1000
+ ? opts.pollUntilDone(context)
1001
+ : opts.plainPollUntilDone(opts.profileDir), opts.heartbeatMessage);
1002
+ if (ok) {
1003
+ if (context !== undefined) {
1004
+ if (opts.onSuccess !== undefined) {
1005
+ try {
1006
+ await opts.onSuccess(context);
1007
+ }
1008
+ catch { /* swallow */ }
1009
+ }
1010
+ }
1011
+ else if (opts.plainOnSuccess !== undefined) {
1012
+ try {
1013
+ await opts.plainOnSuccess(opts.profileDir);
1014
+ }
1015
+ catch { /* swallow */ }
847
1016
  }
848
- catch { /* swallow */ }
849
1017
  }
850
- await context.close();
1018
+ await teardownContext();
851
1019
  return { status: ok ? "completed" : "timeout" };
852
1020
  }
853
1021
  finally {
854
- await context.close().catch(() => undefined);
855
- // Closed — the signal handler must not double-close it.
1022
+ // Idempotent (self-launch teardown guards with a `torn` flag; the
1023
+ // persistent fallback's close() is .catch-wrapped), so the success-path
1024
+ // call above and this finally can both fire safely.
1025
+ await teardownContext();
1026
+ // Torn down — the signal handler must not double-tear it.
856
1027
  activeContext = undefined;
1028
+ activeTeardown = undefined;
857
1029
  }
858
1030
  }
859
1031
  finally {
@@ -964,7 +1136,10 @@ export async function ensureOAuthSession(opts) {
964
1136
  // "Google Account: Ada Lovelace (ada@example.com)" on every Google surface, so
965
1137
  // prefer that anchored match; fall back to the first email-shaped token (the
966
1138
  // myaccount.google.com page renders the address prominently). Pure + exported
967
- // for unit tests — the live navigation that feeds it is captureGoogleEmail.
1139
+ // for unit tests. (Eager capture-at-login was removed with the plain-login
1140
+ // switch — it needed a live CDP context to scrape myaccount.google.com, which
1141
+ // the plain connect browser no longer has; provision scrapes the email per-run
1142
+ // when the marker is unset, so this is now the only consumer besides tests.)
968
1143
  const EMAIL_TOKEN_RE = /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i;
969
1144
  export function extractGoogleAccountEmail(pageText) {
970
1145
  const chip = /Google Account:[^()]*\(([^)]+)\)/i.exec(pageText);
@@ -976,29 +1151,6 @@ export function extractGoogleAccountEmail(pageText) {
976
1151
  const any = EMAIL_TOKEN_RE.exec(pageText);
977
1152
  return any !== null ? any[0].trim() : null;
978
1153
  }
979
- // Capture-at-login: with the profile's live Google session, read the account's
980
- // email once and persist it to the profile marker so provision can fill it as
981
- // the signup email (user-owned accounts) without a per-run scrape. Best-effort:
982
- // any failure leaves the marker unset and provision proceeds without a pre-known
983
- // email. Only meaningful for google (the inbox identity); github has no inbox.
984
- async function captureGoogleEmail(context, profileDir) {
985
- let page = null;
986
- try {
987
- page = await context.newPage();
988
- await page.goto("https://myaccount.google.com/", { waitUntil: "domcontentloaded", timeout: 15_000 });
989
- const text = await page.evaluate(() => document.body.innerText).catch(() => "");
990
- const email = extractGoogleAccountEmail(text);
991
- if (email !== null)
992
- recordProviderEmail("google", email, profileDir);
993
- }
994
- catch {
995
- /* best-effort — no pre-known email this run */
996
- }
997
- finally {
998
- if (page !== null)
999
- await page.close().catch(() => undefined);
1000
- }
1001
- }
1002
1154
  // Public entry for the install flow: opens the trustysquire /install
1003
1155
  // confirm URL in the bot's persistent Chrome profile, runs the
1004
1156
  // user-supplied check until the install is claimed (or the deadline
@@ -1017,45 +1169,33 @@ export async function openInstallConfirmInBotChrome(opts) {
1017
1169
  deadline,
1018
1170
  bannerLabel: `You'll see a Chrome window with the Trusty Squire install page. ` +
1019
1171
  `Sign in there to connect this machine — you only sign in once.`,
1020
- pollUntilDone: (context) => opts.pollUntilClaimed(context),
1172
+ // PLAIN browser — no CDP. Google's OAuth flow (which this confirm page
1173
+ // initiates) rejects a CDP-attached Chrome (STATE.md 2026-07-20). The
1174
+ // context-taking pollUntilDone is never invoked in this mode; supply a
1175
+ // stub to satisfy the (CDP-path) type.
1176
+ plainProfileLogin: true,
1177
+ pollUntilDone: () => Promise.resolve(false),
1178
+ plainPollUntilDone: (dir) => opts.pollUntilClaimed(dir),
1021
1179
  ...(opts.apiBaseUrl !== undefined ? { apiBaseUrl: opts.apiBaseUrl } : {}),
1022
1180
  ...(opts.heartbeatMessage !== undefined
1023
1181
  ? { heartbeatMessage: opts.heartbeatMessage }
1024
1182
  : {}),
1025
- // The user's sign-in inside this Chrome leaves a provider session
1026
- // in the persistent profile. We don't know WHICH provider they
1027
- // used (Google or GitHub), so probe both cookie sets and mark
1028
- // whichever has live cookies. Runs while the context is still
1029
- // open — opening a second persistent context to the same profile
1030
- // right after teardown is racy and silently fails on profile-
1031
- // lock contention, which would leave the marker file empty and
1032
- // make the signup bot fall back to manual on every subsequent
1033
- // OAuth-only service.
1034
- onSuccess: async (context) => {
1183
+ // The user's sign-in inside this Chrome leaves a provider session in the
1184
+ // persistent profile. We don't know WHICH provider they used, so probe
1185
+ // both cookie sets (from the on-disk store — no live context in plain
1186
+ // mode) and mark whichever seeded.
1187
+ plainOnSuccess: async (dir) => {
1035
1188
  for (const provider of ["google", "github"]) {
1036
- const target = LOGIN_TARGETS[provider];
1037
- // Probe BOTH cookie origins — modern Google cookies are
1038
- // `.google.com` domain so visible at www.google.com, but
1039
- // some get set on accounts.google.com specifically. Checking
1040
- // both catches the OAuth-redirect case (the user came
1041
- // through accounts.google.com, never visited www.google.com).
1042
- const origins = [target.cookieOrigin, "https://accounts.google.com"];
1043
- let hit = false;
1044
- for (const origin of origins) {
1045
- const cookies = await context.cookies(origin);
1046
- if (cookies.some((c) => target.cookies.includes(c.name))) {
1047
- hit = true;
1048
- break;
1049
- }
1050
- }
1051
- if (hit) {
1052
- markProviderLoggedIn(provider, profileDir);
1053
- // Capture-at-login: store the Google email now (the one moment it's
1054
- // certain) so provision fills it as the user-owned signup email.
1055
- if (provider === "google")
1056
- await captureGoogleEmail(context, profileDir);
1189
+ if (profileHasProviderCookies(dir, provider)) {
1190
+ markProviderLoggedIn(provider, dir);
1057
1191
  }
1058
1192
  }
1193
+ // NB: eager Google-email capture (captureGoogleEmail) needed a live
1194
+ // context to scrape myaccount.google.com; the plain login path has
1195
+ // none. It was only an optimization ("provision proceeds without a
1196
+ // pre-known email" otherwise) — provision scrapes the email per-run
1197
+ // when unset, so dropping eager capture is safe and keeping CDP off
1198
+ // the OAuth login is the whole point of the plain path.
1059
1199
  },
1060
1200
  });
1061
1201
  if (result.status === "completed") {