@wlv-zedd/dsh-chatgpt-web 1.0.0

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.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/assets/demo.gif +0 -0
  4. package/assets/hero-demo.png +0 -0
  5. package/assets/promo-dshmarket-official.png +0 -0
  6. package/cordis.patch.yml +4 -0
  7. package/lib/cli.js +239642 -0
  8. package/lib/plugin.js +195 -0
  9. package/package.json +88 -0
  10. package/screenshots.json +5 -0
  11. package/src/adapters/base.ts +16 -0
  12. package/src/adapters/chatgpt-web/adapter-error.ts +59 -0
  13. package/src/adapters/chatgpt-web/browser-helper-main.ts +513 -0
  14. package/src/adapters/chatgpt-web/browser-helper-prompt-selection.ts +27 -0
  15. package/src/adapters/chatgpt-web/browser-worker.ts +4944 -0
  16. package/src/adapters/chatgpt-web/codex-rollout-environment.ts +628 -0
  17. package/src/adapters/chatgpt-web/compaction-handoff.ts +533 -0
  18. package/src/adapters/chatgpt-web/compaction-transaction.ts +142 -0
  19. package/src/adapters/chatgpt-web/concurrency.ts +6 -0
  20. package/src/adapters/chatgpt-web/conversation-key.ts +58 -0
  21. package/src/adapters/chatgpt-web/environment.ts +669 -0
  22. package/src/adapters/chatgpt-web/index.ts +1544 -0
  23. package/src/adapters/chatgpt-web/input-tokens.ts +74 -0
  24. package/src/adapters/chatgpt-web/launcher-helper-client.ts +695 -0
  25. package/src/adapters/chatgpt-web/markdown.ts +418 -0
  26. package/src/adapters/chatgpt-web/mcp-main.ts +25 -0
  27. package/src/adapters/chatgpt-web/mcp-server.ts +933 -0
  28. package/src/adapters/chatgpt-web/model.ts +70 -0
  29. package/src/adapters/chatgpt-web/native-compaction-control.ts +74 -0
  30. package/src/adapters/chatgpt-web/output-validation.ts +62 -0
  31. package/src/adapters/chatgpt-web/process-line-writer.ts +46 -0
  32. package/src/adapters/chatgpt-web/prompt.ts +702 -0
  33. package/src/adapters/chatgpt-web/retry-policy.ts +73 -0
  34. package/src/adapters/chatgpt-web/rolling-checkpoint.ts +384 -0
  35. package/src/adapters/chatgpt-web/thread-environment.ts +238 -0
  36. package/src/adapters/chatgpt-web/tool-stream-parser.ts +601 -0
  37. package/src/adapters/chatgpt-web/turn-broker.ts +1481 -0
  38. package/src/adapters/chatgpt-web/turn-execution.ts +816 -0
  39. package/src/adapters/chatgpt-web/turn-progress.ts +292 -0
  40. package/src/adapters/chatgpt-web/usage.ts +121 -0
  41. package/src/adapters/image.ts +9 -0
  42. package/src/bridge.ts +1083 -0
  43. package/src/browser-login.ts +521 -0
  44. package/src/chatgpt-session.ts +240 -0
  45. package/src/chatgpt-web-models.ts +400 -0
  46. package/src/cli.ts +568 -0
  47. package/src/codex-integration-document.ts +824 -0
  48. package/src/codex-integration-journal.ts +212 -0
  49. package/src/codex-integration-route.ts +515 -0
  50. package/src/codex-integration-shared.ts +332 -0
  51. package/src/codex-integration.ts +529 -0
  52. package/src/codex-interrupt-hook.ts +158 -0
  53. package/src/config.ts +616 -0
  54. package/src/dev-chat/cli.ts +432 -0
  55. package/src/dev-chat/constants.ts +3 -0
  56. package/src/dev-chat/driver.ts +655 -0
  57. package/src/dev-chat/profile.ts +223 -0
  58. package/src/dev-chat/session.ts +287 -0
  59. package/src/dev-chat/transport.ts +54 -0
  60. package/src/doctor.ts +237 -0
  61. package/src/event-queue.ts +45 -0
  62. package/src/http-body.ts +30 -0
  63. package/src/launcher-browser-host.ts +695 -0
  64. package/src/lib/errors.ts +281 -0
  65. package/src/lib/token-estimate.ts +42 -0
  66. package/src/login-helper.cjs +140 -0
  67. package/src/model-catalog.ts +197 -0
  68. package/src/native-passthrough.ts +261 -0
  69. package/src/plugin.ts +191 -0
  70. package/src/process.ts +45 -0
  71. package/src/responses/compaction.ts +199 -0
  72. package/src/responses/parser.ts +633 -0
  73. package/src/responses/reasoning-envelope.ts +49 -0
  74. package/src/responses/schema.ts +172 -0
  75. package/src/responses/state.ts +230 -0
  76. package/src/server.ts +1111 -0
  77. package/src/service.ts +315 -0
  78. package/src/setup.ts +671 -0
  79. package/src/stall-timeout.ts +23 -0
  80. package/src/tunnel-service.ts +160 -0
  81. package/src/tunnel.ts +417 -0
  82. package/src/turndown-plugin-gfm.d.ts +5 -0
  83. package/src/types.ts +307 -0
  84. package/src/usage/totals.ts +12 -0
  85. package/src/version.ts +1 -0
@@ -0,0 +1,521 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { chromium, type BrowserContext, type BrowserContextOptions, type Page } from "playwright-core";
5
+ import type { AppConfig } from "./config";
6
+ import { atomicWriteFile } from "./config";
7
+ import {
8
+ assertAuthenticatedChatGptPage,
9
+ assertTemporaryChatPage,
10
+ CHATGPT_TEMPORARY_CHAT_URL,
11
+ detectChatGptAccountCapabilities,
12
+ } from "./chatgpt-session";
13
+ import type { ChatGptWebAccountCapabilities } from "./chatgpt-web-models";
14
+
15
+ export interface BrowserLoginResult {
16
+ storageStatePath: string;
17
+ accountSurfaceUrl: string;
18
+ solAvailable: boolean;
19
+ proAvailable: boolean;
20
+ }
21
+
22
+ export type BrowserLoginStorageState = Awaited<ReturnType<BrowserContext["storageState"]>>;
23
+
24
+ export interface SystemBrowserLoginCaptureMarker {
25
+ version: 1;
26
+ captureComplete: true;
27
+ source: "isolated-normal-browser-profile";
28
+ capturedAt: string;
29
+ }
30
+
31
+ export interface SystemBrowserLoginCapture {
32
+ storageState: BrowserLoginStorageState;
33
+ marker: SystemBrowserLoginCaptureMarker;
34
+ }
35
+
36
+ interface SystemBrowserLoginOptions {
37
+ continuation: Promise<void>;
38
+ timeoutMs?: number;
39
+ }
40
+
41
+ interface LoginVerificationMarker {
42
+ version: 1;
43
+ authenticated: true;
44
+ verifiedAt: string;
45
+ solAvailable?: boolean;
46
+ proAvailable?: boolean;
47
+ }
48
+
49
+ const SYSTEM_LOGIN_TIMEOUT_MS = 10 * 60_000;
50
+ const SYSTEM_LOGIN_STOP_TIMEOUT_MS = 5_000;
51
+ const LOGIN_STORAGE_ROOT_DOMAINS = ["chatgpt.com", "openai.com"] as const;
52
+ const CHATGPT_ORIGIN = new URL(CHATGPT_TEMPORARY_CHAT_URL).origin;
53
+
54
+ function browserProcessExited(browser: ChildProcess): boolean {
55
+ return browser.exitCode !== null || browser.signalCode !== null;
56
+ }
57
+
58
+ function removeTemporaryChromeTabSessions(profileDir: string): void {
59
+ const defaultProfile = join(profileDir, "Default");
60
+ rmSync(join(defaultProfile, "Sessions"), { recursive: true, force: true });
61
+ for (const name of ["Current Session", "Current Tabs", "Last Session", "Last Tabs"]) {
62
+ rmSync(join(defaultProfile, name), { force: true });
63
+ }
64
+ }
65
+
66
+ async function waitForBrowserExit(browser: ChildProcess, timeoutMs: number): Promise<boolean> {
67
+ if (browserProcessExited(browser)) return true;
68
+ return await new Promise(resolve => {
69
+ let settled = false;
70
+ const finish = (exited: boolean) => {
71
+ if (settled) return;
72
+ settled = true;
73
+ clearTimeout(timer);
74
+ browser.off("exit", onExit);
75
+ resolve(exited);
76
+ };
77
+ const onExit = () => finish(true);
78
+ const timer = setTimeout(() => finish(false), timeoutMs);
79
+ browser.once("exit", onExit);
80
+ if (browserProcessExited(browser)) finish(true);
81
+ });
82
+ }
83
+
84
+ async function stopOwnedLoginBrowser(browser: ChildProcess): Promise<void> {
85
+ if (browserProcessExited(browser) || !Number.isInteger(browser.pid)) return;
86
+ const graceful = waitForBrowserExit(browser, SYSTEM_LOGIN_STOP_TIMEOUT_MS);
87
+ if (!browser.kill() && !browserProcessExited(browser)) {
88
+ throw new Error("The dedicated Chrome login process refused to close");
89
+ }
90
+ if (await graceful) return;
91
+ const forced = waitForBrowserExit(browser, SYSTEM_LOGIN_STOP_TIMEOUT_MS);
92
+ if (!browser.kill("SIGKILL") && !browserProcessExited(browser)) {
93
+ throw new Error("The dedicated Chrome login process refused forced termination");
94
+ }
95
+ if (!await forced) throw new Error("The dedicated Chrome login process did not exit");
96
+ }
97
+
98
+ function allowedLoginStorageHost(rawHostname: string): boolean {
99
+ const hostname = rawHostname.toLowerCase();
100
+ if (!/^[a-z0-9.-]+$/.test(hostname)
101
+ || hostname.startsWith(".")
102
+ || hostname.endsWith(".")
103
+ || hostname.includes("..")) return false;
104
+ try {
105
+ const parsed = new URL(`https://${hostname}/`);
106
+ if (parsed.hostname !== hostname
107
+ || parsed.host !== hostname
108
+ || parsed.username
109
+ || parsed.password
110
+ || parsed.pathname !== "/"
111
+ || parsed.search
112
+ || parsed.hash) return false;
113
+ } catch {
114
+ return false;
115
+ }
116
+ return LOGIN_STORAGE_ROOT_DOMAINS.some(root => hostname === root || hostname.endsWith(`.${root}`));
117
+ }
118
+
119
+ export function sanitizeBrowserLoginStorageState(
120
+ storageState: BrowserLoginStorageState,
121
+ ): BrowserLoginStorageState {
122
+ return {
123
+ cookies: storageState.cookies
124
+ .filter(cookie => !Object.prototype.hasOwnProperty.call(cookie, "partitionKey")
125
+ && allowedLoginStorageHost(cookie.domain.replace(/^\.+/, "")))
126
+ .map(cookie => ({ ...cookie })),
127
+ origins: storageState.origins
128
+ .filter(origin => origin.origin === CHATGPT_ORIGIN)
129
+ .map(origin => ({
130
+ origin: origin.origin,
131
+ localStorage: origin.localStorage.map(item => ({ ...item })),
132
+ })),
133
+ };
134
+ }
135
+
136
+ export function loginVerificationMarkerPath(storageStatePath: string): string {
137
+ return `${storageStatePath}.verified.json`;
138
+ }
139
+
140
+ function writeVerificationMarker(
141
+ storageStatePath: string,
142
+ capabilities: ChatGptWebAccountCapabilities,
143
+ ): void {
144
+ const marker: LoginVerificationMarker = {
145
+ version: 1,
146
+ authenticated: true,
147
+ verifiedAt: new Date().toISOString(),
148
+ ...capabilities,
149
+ };
150
+ atomicWriteFile(loginVerificationMarkerPath(storageStatePath), `${JSON.stringify(marker)}\n`);
151
+ }
152
+
153
+ async function inspectStoredState(
154
+ config: AppConfig,
155
+ storageState: NonNullable<BrowserContextOptions["storageState"]>,
156
+ ): Promise<ChatGptWebAccountCapabilities & { url: string }> {
157
+ const verifierBrowser = await chromium.launch({
158
+ executablePath: config.chromeExecutablePath,
159
+ headless: false,
160
+ ignoreDefaultArgs: ["--enable-automation", "--password-store=basic", "--use-mock-keychain"],
161
+ args: ["--disable-blink-features=AutomationControlled", "--no-first-run", "--no-default-browser-check"],
162
+ });
163
+ try {
164
+ const verifierContext = await verifierBrowser.newContext({ storageState });
165
+ try {
166
+ const verifierPage = await verifierContext.newPage();
167
+ await verifierPage.goto(CHATGPT_TEMPORARY_CHAT_URL, { waitUntil: "domcontentloaded", timeout: 60_000 });
168
+ await verifierPage.getByRole("textbox", { name: "Chat with ChatGPT" }).waitFor({ state: "visible", timeout: 60_000 });
169
+ await assertAuthenticatedChatGptPage(verifierPage);
170
+ await assertTemporaryChatPage(verifierPage);
171
+ return { ...await detectChatGptAccountCapabilities(verifierPage), url: verifierPage.url() };
172
+ } finally {
173
+ await verifierContext.close();
174
+ }
175
+ } finally {
176
+ await verifierBrowser.close();
177
+ }
178
+ }
179
+
180
+ export async function inspectBrowserLoginCapabilities(config: AppConfig): Promise<ChatGptWebAccountCapabilities> {
181
+ if (!browserLoginStateExists(config)) throw new Error("ChatGPT login state is missing or unverified");
182
+ const inspected = await inspectStoredState(config, config.storageStatePath);
183
+ writeVerificationMarker(config.storageStatePath, inspected);
184
+ return { solAvailable: inspected.solAvailable, proAvailable: inspected.proAvailable };
185
+ }
186
+
187
+ export function storedBrowserLoginCapabilities(
188
+ config: AppConfig,
189
+ ): Partial<ChatGptWebAccountCapabilities> {
190
+ if (!browserLoginStateExists(config)) return {};
191
+ try {
192
+ const marker = JSON.parse(readFileSync(loginVerificationMarkerPath(config.storageStatePath), "utf8")) as Partial<LoginVerificationMarker>;
193
+ return {
194
+ ...(typeof marker.solAvailable === "boolean" ? { solAvailable: marker.solAvailable } : {}),
195
+ ...(typeof marker.proAvailable === "boolean" ? { proAvailable: marker.proAvailable } : {}),
196
+ };
197
+ } catch {
198
+ return {};
199
+ }
200
+ }
201
+
202
+ export async function captureSystemBrowserLogin(
203
+ config: Pick<AppConfig, "chromeExecutablePath" | "storageStatePath">,
204
+ options: SystemBrowserLoginOptions,
205
+ ): Promise<SystemBrowserLoginCapture> {
206
+ if (process.platform !== "darwin") {
207
+ throw new Error("Passkey sign-in is currently supported only on macOS");
208
+ }
209
+ if (!existsSync(config.chromeExecutablePath)) {
210
+ throw new Error(`Google Chrome was not found at ${config.chromeExecutablePath}`);
211
+ }
212
+ const timeoutMs = options.timeoutMs ?? SYSTEM_LOGIN_TIMEOUT_MS;
213
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 1) {
214
+ throw new Error("Passkey sign-in timeout must be a positive finite number");
215
+ }
216
+ const deadline = Date.now() + timeoutMs;
217
+ const remainingTime = () => {
218
+ const remaining = deadline - Date.now();
219
+ if (remaining < 1) throw new Error("Timed out waiting for passkey sign-in");
220
+ return remaining;
221
+ };
222
+
223
+ const profileParent = dirname(config.storageStatePath);
224
+ mkdirSync(profileParent, { recursive: true, mode: 0o700 });
225
+ try { chmodSync(profileParent, 0o700); } catch {}
226
+ const profileDir = mkdtempSync(join(profileParent, "login-profile-"));
227
+ try { chmodSync(profileDir, 0o700); } catch {}
228
+ process.stdout.write(
229
+ "Sign in with your passkey in the dedicated Chrome window. When Temporary Chat is ready, return to Codex Web GPT and choose Continue.\n",
230
+ );
231
+
232
+ let capture: SystemBrowserLoginCapture | undefined;
233
+ let context: BrowserContext | undefined;
234
+ let primaryError: unknown;
235
+ try {
236
+ const loginBrowser = spawn(config.chromeExecutablePath, [
237
+ `--user-data-dir=${profileDir}`,
238
+ "--new-window",
239
+ "--disable-background-mode",
240
+ "--no-first-run",
241
+ "--no-default-browser-check",
242
+ CHATGPT_TEMPORARY_CHAT_URL,
243
+ ], { env: process.env, stdio: "ignore" });
244
+ let continuationRequested = false;
245
+ let timeout: ReturnType<typeof setTimeout> | undefined;
246
+ try {
247
+ await new Promise<void>((resolve, reject) => {
248
+ timeout = setTimeout(() => reject(new Error("Timed out waiting for passkey sign-in")), remainingTime());
249
+ void options.continuation.then(() => {
250
+ continuationRequested = true;
251
+ if (!loginBrowser.kill() && !browserProcessExited(loginBrowser)) {
252
+ reject(new Error("The dedicated Chrome login process refused the Continue request"));
253
+ }
254
+ }, reject);
255
+ loginBrowser.once("error", reject);
256
+ loginBrowser.once("exit", (code, signal) => {
257
+ if (continuationRequested) resolve();
258
+ else if (signal) reject(new Error(`Dedicated Chrome login exited from signal ${signal}`));
259
+ else if (code === 0) reject(new Error("Dedicated Chrome closed before Continue was selected"));
260
+ else reject(new Error(`Dedicated Chrome login exited with status ${code ?? 1}`));
261
+ });
262
+ });
263
+ } catch (error) {
264
+ try {
265
+ await stopOwnedLoginBrowser(loginBrowser);
266
+ } catch (cleanupError) {
267
+ const primary = error instanceof Error ? error.message : String(error);
268
+ const cleanup = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
269
+ throw new Error(`${primary}; Chrome cleanup also failed: ${cleanup}`);
270
+ }
271
+ throw error;
272
+ } finally {
273
+ if (timeout) clearTimeout(timeout);
274
+ }
275
+
276
+ // Authentication happens before Playwright ever owns this profile. Chrome does not load
277
+ // session-only cookies after a normal restart unless session restore is requested. Remove only
278
+ // the disposable profile's tab-session files first, so restoring cookies cannot reopen the
279
+ // authenticated or identity-provider pages during the offline capture.
280
+ removeTemporaryChromeTabSessions(profileDir);
281
+ context = await chromium.launchPersistentContext(profileDir, {
282
+ executablePath: config.chromeExecutablePath,
283
+ headless: true,
284
+ chromiumSandbox: true,
285
+ offline: true,
286
+ serviceWorkers: "block",
287
+ ignoreDefaultArgs: [
288
+ "--no-sandbox",
289
+ "--enable-automation",
290
+ "--password-store=basic",
291
+ "--use-mock-keychain",
292
+ ],
293
+ args: [
294
+ "--disable-background-mode",
295
+ "--disable-background-networking",
296
+ "--no-first-run",
297
+ "--no-default-browser-check",
298
+ "--restore-last-session",
299
+ ],
300
+ timeout: Math.min(30_000, remainingTime()),
301
+ });
302
+ await context.setOffline(true);
303
+ await context.route("**/*", route => route.fulfill({
304
+ status: 200,
305
+ contentType: "text/html",
306
+ body: "<!doctype html><meta charset=\"utf-8\"><title>Private login-state capture</title>",
307
+ }));
308
+ const page = context.pages()[0] ?? await context.newPage();
309
+ await page.goto(CHATGPT_TEMPORARY_CHAT_URL, {
310
+ waitUntil: "domcontentloaded",
311
+ timeout: Math.min(60_000, remainingTime()),
312
+ });
313
+ if (new URL(page.url()).origin !== CHATGPT_ORIGIN) {
314
+ throw new Error("Offline passkey-state capture reached an unexpected origin");
315
+ }
316
+ const storageState = sanitizeBrowserLoginStorageState(await context.storageState());
317
+ if (storageState.cookies.length === 0) {
318
+ throw new Error("The dedicated Chrome profile contains no ChatGPT/OpenAI cookies");
319
+ }
320
+ capture = {
321
+ storageState,
322
+ marker: {
323
+ version: 1,
324
+ captureComplete: true,
325
+ source: "isolated-normal-browser-profile",
326
+ capturedAt: new Date().toISOString(),
327
+ },
328
+ };
329
+ } catch (error) {
330
+ primaryError = error;
331
+ }
332
+
333
+ let cleanupError: unknown;
334
+ try {
335
+ if (context && !context.isClosed()) await context.close();
336
+ } catch (error) {
337
+ cleanupError = error;
338
+ }
339
+ try {
340
+ rmSync(profileDir, { recursive: true, force: true });
341
+ } catch (error) {
342
+ cleanupError ??= error;
343
+ }
344
+ if (primaryError) {
345
+ if (cleanupError) {
346
+ throw new Error(
347
+ `${primaryError instanceof Error ? primaryError.message : String(primaryError)}; temporary-profile cleanup also failed:`
348
+ + ` ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`,
349
+ );
350
+ }
351
+ throw primaryError;
352
+ }
353
+ if (cleanupError) throw cleanupError;
354
+ if (!capture) throw new Error("Passkey sign-in completed without capture evidence");
355
+ return capture;
356
+ }
357
+
358
+ export async function captureSystemBrowserLoginToFile(
359
+ config: Pick<AppConfig, "chromeExecutablePath" | "storageStatePath">,
360
+ options: SystemBrowserLoginOptions,
361
+ ): Promise<void> {
362
+ const capture = await captureSystemBrowserLogin(config, options);
363
+ const markerPath = loginVerificationMarkerPath(config.storageStatePath);
364
+ rmSync(markerPath, { force: true });
365
+ atomicWriteFile(config.storageStatePath, `${JSON.stringify(capture.storageState)}\n`);
366
+ atomicWriteFile(markerPath, `${JSON.stringify(capture.marker)}\n`);
367
+ }
368
+
369
+ export async function loginToChatGpt(
370
+ config: AppConfig,
371
+ options: { timeoutMs?: number } = {},
372
+ ): Promise<BrowserLoginResult> {
373
+ if (!existsSync(config.chromeExecutablePath)) {
374
+ throw new Error(`Google Chrome was not found at ${config.chromeExecutablePath}. Pass --chrome with its executable path.`);
375
+ }
376
+
377
+ // Under Bun on Windows, Bun's child process pipe implementation has a known limitation with Playwright stdio pipes.
378
+ // Delegate to a dedicated Node process which executes Playwright natively and flawlessly.
379
+ if (process.platform === "win32" && typeof (process.versions as Record<string, string>).bun === "string") {
380
+ const helperScript = join(import.meta.dirname, "login-helper.cjs");
381
+ if (existsSync(helperScript)) {
382
+ process.stdout.write(
383
+ "A Chrome window is open. Sign in to ChatGPT in the window. Once logged in, this setup will automatically capture your session.\n",
384
+ );
385
+ return await new Promise<BrowserLoginResult>((resolve, reject) => {
386
+ const child = spawn("node", [helperScript, JSON.stringify({ config, options })], {
387
+ stdio: ["ignore", "pipe", "inherit"],
388
+ windowsHide: false,
389
+ });
390
+ let output = "";
391
+ child.stdout?.on("data", chunk => {
392
+ output += chunk.toString();
393
+ });
394
+ child.on("error", reject);
395
+ child.on("exit", (code, signal) => {
396
+ if (code === 0) {
397
+ const match = output.match(/__RESULT__:(.+)/);
398
+ if (match && match[1]) {
399
+ try {
400
+ resolve(JSON.parse(match[1]) as BrowserLoginResult);
401
+ return;
402
+ } catch (err) {
403
+ reject(err);
404
+ return;
405
+ }
406
+ }
407
+ reject(new Error("Login completed but result payload was missing"));
408
+ } else {
409
+ reject(new Error(`Login helper exited with code ${code ?? signal}`));
410
+ }
411
+ });
412
+ });
413
+ }
414
+ }
415
+
416
+ const profileParent = dirname(config.storageStatePath);
417
+ mkdirSync(profileParent, { recursive: true, mode: 0o700 });
418
+ const profileDir = mkdtempSync(join(profileParent, "login-profile-"));
419
+ process.stdout.write(
420
+ "A Chrome window is open. Sign in to ChatGPT in the window. Once logged in, this setup will automatically capture your session.\n",
421
+ );
422
+
423
+ const context = await chromium.launchPersistentContext(profileDir, {
424
+ executablePath: config.chromeExecutablePath,
425
+ headless: false,
426
+ ignoreDefaultArgs: [
427
+ "--enable-automation",
428
+ "--password-store=basic",
429
+ "--use-mock-keychain",
430
+ ],
431
+ args: [
432
+ "--disable-blink-features=AutomationControlled",
433
+ "--no-first-run",
434
+ "--no-default-browser-check",
435
+ ],
436
+ });
437
+ try {
438
+ const page = context.pages()[0] ?? await context.newPage();
439
+ try {
440
+ await page.goto(CHATGPT_TEMPORARY_CHAT_URL, {
441
+ waitUntil: "domcontentloaded",
442
+ timeout: 60_000,
443
+ });
444
+ } catch {
445
+ // In case navigation is redirected immediately (e.g. to login portal)
446
+ }
447
+
448
+ const deadline = Date.now() + (options.timeoutMs ?? 300_000);
449
+ let activePage: Page | undefined;
450
+ while (Date.now() < deadline) {
451
+ for (const p of context.pages()) {
452
+ try {
453
+ const composer = p.locator(
454
+ '[data-testid="prompt-textarea"], #prompt-textarea, [contenteditable="true"][data-lexical-editor="true"]',
455
+ ).first();
456
+ if (await composer.isVisible().catch(() => false)) {
457
+ activePage = p;
458
+ break;
459
+ }
460
+ } catch {}
461
+ }
462
+ if (activePage) break;
463
+ await new Promise(r => setTimeout(r, 1000));
464
+ }
465
+
466
+ if (!activePage) {
467
+ throw new Error("The authenticated ChatGPT page did not produce a visible composer within 5 minutes");
468
+ }
469
+
470
+ await assertAuthenticatedChatGptPage(activePage);
471
+ const rawState = await context.storageState();
472
+ const state = sanitizeBrowserLoginStorageState(rawState);
473
+
474
+ let capabilities: ChatGptWebAccountCapabilities;
475
+ try {
476
+ capabilities = await detectChatGptAccountCapabilities(activePage, { selectorTimeoutMs: 15_000 });
477
+ } catch {
478
+ capabilities = { solAvailable: true, proAvailable: false };
479
+ }
480
+
481
+ atomicWriteFile(config.storageStatePath, `${JSON.stringify(state)}\n`);
482
+ writeVerificationMarker(config.storageStatePath, capabilities);
483
+ return {
484
+ storageStatePath: config.storageStatePath,
485
+ accountSurfaceUrl: activePage.url(),
486
+ solAvailable: capabilities.solAvailable,
487
+ proAvailable: capabilities.proAvailable,
488
+ };
489
+ } finally {
490
+ await context.close().catch(() => {});
491
+ if (browserLoginStateExists(config)) rmSync(profileDir, { recursive: true, force: true });
492
+ }
493
+ }
494
+
495
+ export function browserLoginStateExists(config: AppConfig): boolean {
496
+ if (!existsSync(config.storageStatePath)) return false;
497
+ const markerPath = loginVerificationMarkerPath(config.storageStatePath);
498
+ if (!existsSync(markerPath)) return false;
499
+ try {
500
+ const marker = JSON.parse(readFileSync(markerPath, "utf8")) as Partial<LoginVerificationMarker>;
501
+ return marker.version === 1 && marker.authenticated === true && typeof marker.verifiedAt === "string";
502
+ } catch {
503
+ return false;
504
+ }
505
+ }
506
+
507
+ export async function checkBrowserEngine(config: AppConfig): Promise<void> {
508
+ if (!existsSync(config.chromeExecutablePath)) throw new Error(`Google Chrome was not found at ${config.chromeExecutablePath}`);
509
+ const browser = await chromium.launch({
510
+ executablePath: config.chromeExecutablePath,
511
+ headless: true,
512
+ args: ["--no-first-run", "--no-default-browser-check"],
513
+ });
514
+ try {
515
+ const page = await browser.newPage();
516
+ await page.goto("about:blank");
517
+ if (await page.evaluate(() => document.readyState) !== "complete") throw new Error("Browser page did not reach complete state");
518
+ } finally {
519
+ await browser.close();
520
+ }
521
+ }