cookie-import-operation.ts
232 lines · browse/src/cookie-import-operation.ts at 3f79d81
1import type { Page } from 'playwright';
2import {
3 CookieImportError, assertCookieDatabase, cookieDomainMatches, importCookies, importCookiesViaCdp,
4 listDomains, listProfiles, normalizeCookieDomain, resolveBrowserInfo, withCookieReadRetry,
5 type ProfileEntry,
6} from './cookie-import-browser';
7import { clearCookieTargetStorage, validateCookieAuthOptions, validateCookieStorageSupport, verifyCookieAuthentication, type CookieAuthVerificationOptions } from './cookie-auth-verification';
9export interface CookieImportTarget {
10 page: Page;
11 url: string;
12}
14export interface CookieImportOptions {
15 browser: string;
16 domains?: string[];
17 profile?: string;
18 all?: boolean;
19 clearStorage?: boolean;
20 verifyAuth?: boolean;
21}
23const activeImports = new WeakSet<object>();
25export function parseCookieImportArgs(args: string[]): CookieImportOptions {
26 const options: CookieImportOptions = { browser: 'comet' };
27 let browserSet = false;
28 const seen = new Set<string>();
29 for (let i = 0; i < args.length; i++) {
30 const arg = args[i];
31 if (!arg.startsWith('--')) {
32 if (browserSet) throw new CookieImportError('Specify only one source browser.', 'bad_request');
33 options.browser = arg;
34 browserSet = true;
35 continue;
36 }
37 if (seen.has(arg)) throw new CookieImportError('Duplicate cookie-import option.', 'bad_request');
38 seen.add(arg);
39 if (arg === '--domain' || arg === '--profile') {
40 const value = args[++i];
41 if (!value || value.startsWith('--')) throw new CookieImportError('Cookie-import option requires a value.', 'bad_request');
42 if (arg === '--domain') options.domains = [normalizeCookieDomain(value)];
43 else options.profile = value;
44 } else if (arg === '--all') options.all = true;
45 else if (arg === '--clear-storage') options.clearStorage = true;
46 else if (arg === '--verify-auth') options.verifyAuth = true;
47 else throw new CookieImportError('Unknown cookie-import option.', 'bad_request');
48 }
49 if (options.all && options.domains) throw new CookieImportError('Choose --domain or --all, not both.', 'bad_request');
50 if (options.all && options.clearStorage) throw new CookieImportError('Storage reset requires a single target origin; --all is not supported.', 'bad_request');
51 return options;
52}
54export async function getCookieProfiles(browser: string, domains: string[] = [], hostname?: string) {
55 const profiles: Array<ProfileEntry & { matches?: boolean; unavailable?: boolean }> = listProfiles(browser);
56 if (domains.length || hostname) {
57 const selected = domains.map(normalizeCookieDomain);
58 let index = 0;
59 const check = async () => {
60 while (index < profiles.length) {
61 const profile = profiles[index++];
62 try {
63 const result = await withCookieReadRetry(() => listDomains(browser, profile.name));
64 profile.matches = result.domains.some(entry => selected.length
65 ? selected.includes(normalizeCookieDomain(entry.domain))
66 : cookieDomainMatches(hostname!, entry.domain));
67 } catch (err) {
68 if (err instanceof CookieImportError && err.code === 'sqlite_unavailable') throw err;
69 profile.unavailable = true;
70 }
71 }
72 };
73 await Promise.all([check(), check()]);
74 }
75 const matching = profiles.filter(profile => profile.matches === true);
76 const recommendedProfile = !profiles.some(profile => profile.unavailable) && matching.length === 1
77 ? matching[0].name : profiles.length === 1 && profiles[0].matches !== false && !profiles[0].unavailable ? profiles[0].name : undefined;
78 return { profiles, recommendedProfile };
79}
81type ProfileSuggestion = Array<ProfileEntry & { matches?: boolean; unavailable?: boolean }>;
83function profileSelectionError(browser: string, profiles: ProfileSuggestion, domains: string[]): CookieImportError {
84 const { name, aliases } = resolveBrowserInfo(browser);
85 if (!profiles.length) {
86 assertCookieDatabase(browser, 'Default');
87 return new CookieImportError(`${name} profiles changed while selecting. Retry with --profile "<dir>", or run \`$B cookie-import-browser ${aliases[0]}\` to choose in the picker.`, 'profile_required');
88 }
89 const retry = `Retry with --profile "<dir>", or run \`$B cookie-import-browser ${aliases[0]}\` to choose in the picker.`;
90 const list = (entries: ProfileSuggestion) => entries.map(entry => entry.name).join(', ');
91 const unreadable = profiles.filter(entry => entry.unavailable);
92 const matching = profiles.filter(entry => entry.matches === true);
93 const scope = domains.join(', ');
94 if (unreadable.length) {
95 const found = matching.length ? ` Profiles with cookies for ${scope}: ${list(matching)}.` : '';
96 return new CookieImportError(`${name} profiles ${list(unreadable)} could not be read (the browser may be locking them). Close ${name} and retry, or pass --profile.${found}`, 'profile_required');
97 }
98 if (!domains.length) return new CookieImportError(`${name} has several profiles: ${list(profiles)}. ${retry}`, 'profile_required');
99 if (matching.length > 1) return new CookieImportError(`${name} has several profiles with cookies for ${scope}: ${list(matching)}. ${retry}`, 'profile_required');
100 return new CookieImportError(`No ${name} profile has cookies for ${scope}. Check the domain and that you are signed in to it in ${name}.`, 'profile_required');
101}
103function appBoundMessage(browser: string, result: { count: number; failureReasons?: Record<string, number> }): string {
104 const { name } = resolveBrowserInfo(browser);
105 const recovery = 'Sign in manually: run `$B handoff`, sign in to the intended account in the window that opens, then run `$B resume` (needs a display). See BROWSER.md, Platform limits.';
106 if (result.count) return `Some ${name} cookies use App-Bound Encryption and were skipped, so the session may not be restored. Check with \`$B reload\` (or --verify-auth); if you are signed out, run \`$B handoff\`, sign in, then \`$B resume\`.`;
107 if (result.failureReasons?.decryption_failed) return `${name} cookies could not be imported: some use App-Bound Encryption and others could not be decrypted. ${recovery}`;
108 return `Some selected ${name} cookies use App-Bound Encryption, which gstack cannot decrypt for this browser. ${recovery}`;
109}
111export function validateCookieTarget(target: CookieImportTarget): URL {
112 try {
113 const url = new URL(target.url);
114 if (!['http:', 'https:'].includes(url.protocol)) throw new Error();
115 if (target.page.isClosed() || target.page.url() !== target.url) throw new Error();
116 return url;
117 } catch {
118 throw new CookieImportError('The captured HTTP(S) target has changed or is unavailable. Reopen the picker on the intended page.', 'target_changed');
119 }
120}
122export async function runCookieImport(
123 options: CookieImportOptions,
124 target: CookieImportTarget,
125 trackDomains: (domains: string[]) => void,
126 authOptions: CookieAuthVerificationOptions = {},
127) {
128 for (const value of [options.all, options.clearStorage, options.verifyAuth]) {
129 if (value !== undefined && typeof value !== 'boolean') throw new CookieImportError('Cookie import options must be booleans.', 'bad_request');
130 }
131 if (typeof options.browser !== 'string' || !options.browser.trim()) throw new CookieImportError('Select a source browser.', 'bad_request');
132 if (options.profile !== undefined && (typeof options.profile !== 'string' || !options.profile)) throw new CookieImportError('Invalid source profile.', 'bad_request');
133 if (options.all && options.domains || options.all && options.clearStorage) throw new CookieImportError('All-domain import cannot be combined with a scoped domain or storage reset.', 'bad_request');
134 if (!options.all && (!Array.isArray(options.domains) || !options.domains.length)) throw new CookieImportError('Select at least one cookie domain.', 'bad_request');
135 const selected = options.domains?.map(normalizeCookieDomain) ?? [];
136 const needsTarget = options.clearStorage || options.verifyAuth;
137 const targetUrl = needsTarget ? validateCookieTarget(target) : undefined;
138 if (options.clearStorage) validateCookieStorageSupport(target.page);
139 if (options.verifyAuth) validateCookieAuthOptions(authOptions);
140 if (targetUrl && selected.length && !selected.some(domain => cookieDomainMatches(targetUrl.hostname, '.' + domain))) {
141 throw new CookieImportError('The selected cookies do not match the captured target origin.', 'target_mismatch');
142 }
143 const context = target.page.context();
144 if (target.page.isClosed()) throw new CookieImportError('The captured target is closed.', 'target_closed');
145 if (activeImports.has(context)) throw new CookieImportError('Another cookie import is still running. Wait before retrying.', 'import_busy', 'retry');
146 activeImports.add(context);
147 try {
148 let profile = options.profile;
149 if (!profile) {
150 const suggestion = await getCookieProfiles(options.browser, selected);
151 profile = suggestion.recommendedProfile;
152 if (!profile) throw profileSelectionError(options.browser, suggestion.profiles, selected);
153 }
154 const domains = options.all
155 ? (await withCookieReadRetry(() => listDomains(options.browser, profile!))).domains.map(entry => entry.domain)
156 : selected;
157 let result = await withCookieReadRetry(() => importCookies(options.browser, domains, profile));
158 const appBound = process.platform === 'win32' && result.failureReasons?.unsupported_encryption && !resolveBrowserInfo(options.browser).windowsNative
159 ? appBoundMessage(options.browser, result) : undefined;
160 if (!appBound && result.count === 0 && result.failureReasons?.unsupported_encryption && process.platform === 'win32') {
161 const failed = result.failed;
162 result = await importCookiesViaCdp(options.browser, domains, profile);
163 result.failed = Math.max(result.failed, failed - result.count);
164 if (result.failed) result.failureReasons = { native_unrecovered: result.failed };
165 }
166 const receipt = {
167 browser: options.browser,
168 profile,
169 imported: 0,
170 failed: result.failed,
171 domainCounts: {} as Record<string, number>,
172 failureReasons: result.failureReasons ?? {},
173 outcome: (result.failed ? 'failed' : 'empty') as 'empty' | 'imported' | 'partial' | 'failed',
174 reset: 'not_requested' as 'not_requested' | 'cleared' | 'failed',
175 verification: { verified: false, reason: 'not_requested' } as { verified: boolean; reason: string; status?: number },
176 message: appBound ?? (result.failed ? 'No cookies imported; cookies could not be decrypted.' : 'No matching cookies found.'),
177 };
178 if (!result.count) {
179 if (options.verifyAuth) receipt.verification.reason = 'no_cookies_imported';
180 return receipt;
181 }
182 if (targetUrl && !result.cookies.some(cookie => cookieDomainMatches(targetUrl.hostname, cookie.domain))) {
183 throw new CookieImportError('No imported cookies apply to the captured target origin.', 'target_mismatch');
184 }
185 if (target.page.isClosed()) throw new CookieImportError('The captured target is closed.', 'target_closed');
186 if (needsTarget) validateCookieTarget(target);
187 if (options.clearStorage) {
188 try {
189 await clearCookieTargetStorage(target.page, targetUrl!.origin);
190 receipt.reset = 'cleared';
191 } catch {
192 receipt.outcome = 'failed';
193 receipt.reset = 'failed';
194 receipt.message = 'Storage reset did not complete; storage may be partially cleared. No new cookies were applied.';
195 receipt.verification.reason = 'reset_failed';
196 return receipt;
197 }
198 }
199 const appliedDomains = [...new Set(result.cookies.map(cookie => cookie.domain))];
200 try {
201 await context.addCookies(result.cookies);
202 } catch {
203 trackDomains(appliedDomains);
204 receipt.outcome = 'failed';
205 receipt.message = 'Cookie application failed; the browser may contain a partial import. Authentication was not verified.';
206 receipt.verification.reason = 'application_failed';
207 return receipt;
208 }
209 trackDomains(appliedDomains);
210 receipt.imported = result.count;
211 receipt.domainCounts = result.domainCounts;
212 receipt.outcome = result.failed ? 'partial' : 'imported';
213 receipt.message = appBound ?? (result.failed ? 'Some cookies could not be decrypted.' : 'Cookie copy complete.');
214 if (options.verifyAuth) {
215 try {
216 validateCookieTarget(target);
217 receipt.verification = await verifyCookieAuthentication(target.page, authOptions, targetUrl!.origin);
218 } catch {
219 receipt.verification = { verified: false, reason: 'target_changed' };
220 }
221 }
222 return receipt;
223 } finally {
224 activeImports.delete(context);
225 }
226}
228export function formatCookieImportResult(result: Awaited<ReturnType<typeof runCookieImport>>): string {
229 const reasons = Object.entries(result.failureReasons).sort(([a], [b]) => a.localeCompare(b)).map(([key, count]) => `${key}=${count}`);
230 const reasonText = reasons.length ? ` Failure reasons: ${reasons.join(', ')}.` : '';
231 return `Imported ${result.imported} cookies from ${result.browser} (profile: ${result.profile}); ${result.failed} failed to decrypt. ${result.message}${reasonText} Storage reset: ${result.reset}. Authentication: ${result.verification.reason}.`;
232}