From b1619959a5cb10775f851787171e7e2d60b1919a Mon Sep 17 00:00:00 2001 From: artickc Date: Thu, 6 Aug 2026 03:53:43 +0300 Subject: [PATCH] fix(adobe-firefly): harden CDP warm, risk session, and browser sign-in Stop colligo 408 thrash from stale Forter and frozen Google login during Sign in with browser: - CDP warm: clear Firefly origin storage + risk cookies (keep SSO); require forter age under 10 minutes on loop and timeout paths; dual CDP queues; await Runtime.runIfWaitingForDebugger; profile-lock launch retries - Session: connectionId fingerprint; write-back JWT+Cookie; warm-fail cooldown; fail closed risk_session_stale when forter is known-stale - Client: submit gate around generate-async; max 2 attempts when forter known-stale; poll 401 one refresh; pass sessionBrowserKey through handlers - Login route: pure system Chrome/Edge CDP only; camelCase credential persist - Unit: browser-login + firefly suites green (60) --- .../imageGeneration/providers/adobeFirefly.ts | 21 +- .../videoGeneration/adobeFireflyHandler.ts | 13 +- open-sse/services/adobeFireflyBrowserLogin.ts | 832 ++++++++++-- .../services/adobeFireflyChromeRuntime.ts | 1200 +++++++++++++++++ open-sse/services/adobeFireflyClient.ts | 519 ++++--- open-sse/services/adobeFireflySession.ts | 187 ++- src/app/api/providers/[id]/login/route.ts | 204 ++- .../unit/adobe-firefly-browser-login.test.ts | 92 +- 8 files changed, 2621 insertions(+), 447 deletions(-) create mode 100644 open-sse/services/adobeFireflyChromeRuntime.ts diff --git a/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts b/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts index d419f22c5a..7d320a68ef 100644 --- a/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts +++ b/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts @@ -51,7 +51,17 @@ export async function handleAdobeFireflyImageGeneration({ images?: unknown; [key: string]: unknown; }; - credentials: { apiKey?: string; accessToken?: string }; + credentials: { + apiKey?: string; + accessToken?: string; + connectionId?: string; + providerSpecificData?: { + cookie?: unknown; + access_token?: unknown; + accessToken?: unknown; + browserSessionKey?: unknown; + } | null; + }; log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; fetchImpl?: typeof fetch; }) { @@ -88,10 +98,7 @@ export async function handleAdobeFireflyImageGeneration({ // Cap uploads by model family (matches MediaViewModel GetSourceImageLimit). const { id: resolvedId } = resolveAdobeImageModel(model); - const maxRefs = - resolvedId.includes("nano-banana") || resolvedId.includes("gpt-image") - ? 4 - : 2; + const maxRefs = resolvedId.includes("nano-banana") || resolvedId.includes("gpt-image") ? 4 : 2; const sourceImageIds = await resolveAdobeSourceImageIds({ accessToken, @@ -119,12 +126,12 @@ export async function handleAdobeFireflyImageGeneration({ aspectRatio: body.aspect_ratio ?? body.aspectRatio ?? body.size, quality: body.quality, seed: Number.isFinite(seed as number) ? (seed as number) : undefined, - negativePrompt: - typeof body.negative_prompt === "string" ? body.negative_prompt : undefined, + negativePrompt: typeof body.negative_prompt === "string" ? body.negative_prompt : undefined, sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined, sessionCookie, arpSessionId, sessionFingerprint: session.fingerprint, + sessionBrowserKey: session.browserSessionKey, timeoutMs, fetchImpl, log, diff --git a/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts b/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts index 77e0c7e161..b812bb7b74 100644 --- a/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts +++ b/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts @@ -31,7 +31,17 @@ export async function handleAdobeFireflyVideoGeneration({ provider: string; providerConfig?: { baseUrl?: string }; body: Record; - credentials?: { apiKey?: string; accessToken?: string } | null; + credentials?: { + apiKey?: string; + accessToken?: string; + connectionId?: string; + providerSpecificData?: { + cookie?: unknown; + access_token?: unknown; + accessToken?: unknown; + browserSessionKey?: unknown; + } | null; + } | null; log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; fetchImpl?: typeof fetch; }) { @@ -104,6 +114,7 @@ export async function handleAdobeFireflyVideoGeneration({ sessionCookie, arpSessionId, sessionFingerprint: session.fingerprint, + sessionBrowserKey: session.browserSessionKey, timeoutMs, fetchImpl, log, diff --git a/open-sse/services/adobeFireflyBrowserLogin.ts b/open-sse/services/adobeFireflyBrowserLogin.ts index f40b8a94ad..eb59090edf 100644 --- a/open-sse/services/adobeFireflyBrowserLogin.ts +++ b/open-sse/services/adobeFireflyBrowserLogin.ts @@ -15,22 +15,83 @@ import { spawn, type ChildProcess } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import http from "node:http"; import { createServer } from "node:net"; import { join } from "node:path"; -import { decodeAdobeJwtPayload, isAdobeUserAccessToken } from "./adobeFireflyClient.ts"; +import { + decodeAdobeJwtPayload, + isAdobeUserAccessToken, + looksLikeAdobeJwt, +} from "./adobeFireflyClient.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; +/** + * Loopback HTTP GET that MUST NOT use globalThis.fetch. + * OmniRoute patches fetch with a proxy dispatcher (proxyFetch.ts); routing + * 127.0.0.1 Chrome DevTools through that proxy yields PROXY_UNREACHABLE / + * "Chrome DevTools did not become ready: fetch failed" while Chrome is fine. + */ +function loopbackHttpGetJson( + port: number, + path: string, + timeoutMs = 2000 +): Promise { + return new Promise((resolve, reject) => { + const req = http.get( + { + host: "127.0.0.1", + port, + path, + timeout: Math.max(500, timeoutMs), + headers: { Accept: "application/json" }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c))); + res.on("end", () => { + const body = Buffer.concat(chunks).toString("utf8"); + if ((res.statusCode || 0) < 200 || (res.statusCode || 0) >= 300) { + reject(new Error(`HTTP ${res.statusCode || 0} ${path}`)); + return; + } + try { + resolve(JSON.parse(body || "null") as T); + } catch (err) { + reject(err instanceof Error ? err : new Error(String(err))); + } + }); + } + ); + req.on("timeout", () => { + req.destroy(new Error(`timeout ${timeoutMs}ms ${path}`)); + }); + req.on("error", reject); + }); +} + const FIREFLY_HOME_URL = "https://firefly.adobe.com/"; const FIREFLY_3P_HOST_SUFFIX = "firefly-3p.ff.adobe.io"; // Bounded quantifiers (Hard Rule: avoid ReDoS on adversarial Authorization headers). const ADOBE_BEARER_REGEX = /^Bearer\s+(eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096})/i; +const ADOBE_JWT_IN_TEXT_REGEX = + /eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}/g; const DEFAULT_LOGIN_TIMEOUT_MS = 300_000; const MIN_LOGIN_TIMEOUT_MS = 15_000; const MAX_LOGIN_TIMEOUT_MS = 600_000; const POLL_INTERVAL_MS = 400; -const CDP_READY_TIMEOUT_MS = 30_000; +/** Interactive sign-in must surface Chrome quickly; 12s is enough if spawn works. */ +const CDP_READY_TIMEOUT_MS = 12_000; +const CDP_READY_TIMEOUT_RETRY_MS = 20_000; +/** Risk cookies that go stale and must be re-minted by the SPA (never seed on force warm). */ +const ADOBE_RISK_COOKIE_NAMES = new Set([ + "fortertoken", + "forter", + "arkose", + "sherlocktoken", + "x-arp-session-id", +]); export interface AdobeFireflyBrowserLoginResult { success: boolean; @@ -52,7 +113,65 @@ type AdobeFireflyBrowserLog = { warn?: (...args: unknown[]) => void; }; -let cdpBrowserChain: Promise = Promise.resolve(); +/** + * Separate queues so a multi-minute background Forter warm cannot block + * interactive "Sign in with browser" (and vice versa uses different profile dirs). + */ +let interactiveCdpChain: Promise = Promise.resolve(); +let backgroundCdpChain: Promise = Promise.resolve(); + +/** @deprecated test alias — both chains reset together. */ +export function __resetAdobeFireflyCdpChainsForTests(): void { + interactiveCdpChain = Promise.resolve(); + backgroundCdpChain = Promise.resolve(); +} + +/** True when cookie name is a colligo/Forter risk token (must re-mint, never re-seed stale). */ +export function isAdobeRiskCookieName(name: string): boolean { + return ADOBE_RISK_COOKIE_NAMES.has( + String(name || "") + .trim() + .toLowerCase() + ); +} + +/** Epoch ms embedded in forterToken (`…_{ms}__UDF43…`), or 0. */ +export function extractAdobeForterTimestampFromValue(value: string): number { + const f = String(value || "").trim(); + if (!f) return 0; + let decoded = f; + try { + if (/%[0-9A-Fa-f]{2}/.test(decoded)) decoded = decodeURIComponent(decoded); + } catch { + /* keep */ + } + const m = decoded.match(/_(\d{13})__/); + return m ? Number(m[1]) : 0; +} + +/** Drop stale risk cookies from a seed set so force-warm cannot re-inject a dead Forter. */ +export function filterSeedCookiesForWarm( + cookies: Array<{ name: string; value: string; domain?: string; path?: string }>, + opts?: { dropRiskCookies?: boolean } +): Array<{ name: string; value: string; domain?: string; path?: string }> { + const dropRisk = opts?.dropRiskCookies !== false; + return cookies.filter((c) => { + if (!c?.name || !c?.value) return false; + if (dropRisk && isAdobeRiskCookieName(c.name)) return false; + return true; + }); +} + +/** Pull a user IMS JWT from sessionStorage-ish JSON / raw blobs. */ +export function extractUserJwtFromStorageRaw(raw: string): string { + const matches = String(raw || "").match(ADOBE_JWT_IN_TEXT_REGEX) || []; + // Prefer longest user tokens (guest tokens are shorter / rejected by isAdobeUserAccessToken). + const sorted = [...matches].sort((a, b) => b.length - a.length); + for (const tok of sorted) { + if (looksLikeAdobeJwt(tok) && isAdobeUserAccessToken(tok)) return tok; + } + return ""; +} function resolveAdobeFireflyDataRoot(): string { const dataRoot = @@ -223,16 +342,16 @@ async function waitForCdpReady( let lastError = "CDP endpoint not ready"; while (Date.now() < deadline) { try { - const res = await fetch(`http://127.0.0.1:${port}/json/version`, { - signal: AbortSignal.timeout(2000), - }); - if (res.ok) { - const body = (await res.json()) as { webSocketDebuggerUrl?: string }; - if (body.webSocketDebuggerUrl) { - return { webSocketDebuggerUrl: body.webSocketDebuggerUrl }; - } + // Use node:http — never proxy-patched fetch (see loopbackHttpGetJson). + const body = await loopbackHttpGetJson<{ webSocketDebuggerUrl?: string }>( + port, + "/json/version", + 2000 + ); + if (body?.webSocketDebuggerUrl) { + return { webSocketDebuggerUrl: body.webSocketDebuggerUrl }; } - lastError = `CDP /json/version HTTP ${res.status}`; + lastError = "CDP /json/version missing webSocketDebuggerUrl"; } catch (err) { lastError = err instanceof Error ? err.message : String(err); } @@ -371,16 +490,39 @@ class CdpSocket { }); } - send(method: string, params?: Record, sessionId?: string): Promise { + send( + method: string, + params?: Record, + sessionId?: string, + timeoutMs = 8_000 + ): Promise { const id = this.nextId++; const msg: Record = { id, method }; if (params) msg.params = params; if (sessionId) msg.sessionId = sessionId; return new Promise((resolve, reject) => { - this.pending.set(id, { resolve, reject }); + const timer = setTimeout( + () => { + if (!this.pending.has(id)) return; + this.pending.delete(id); + reject(new Error(`CDP timeout after ${timeoutMs}ms: ${method}`)); + }, + Math.max(500, timeoutMs) + ); + this.pending.set(id, { + resolve: (v) => { + clearTimeout(timer); + resolve(v); + }, + reject: (e) => { + clearTimeout(timer); + reject(e); + }, + }); try { this.ws.send(JSON.stringify(msg)); } catch (err) { + clearTimeout(timer); this.pending.delete(id); reject(err instanceof Error ? err : new Error(String(err))); } @@ -388,6 +530,10 @@ class CdpSocket { } close(): void { + for (const [id, p] of this.pending) { + this.pending.delete(id); + p.reject(new Error("CDP socket closed")); + } try { this.ws.close(); } catch { @@ -418,6 +564,8 @@ async function openCdp(url: string): Promise { /** * Capture Firefly IMS JWT by watching Network.requestWillBeSent on all page targets. + * Background warm (`waitForRiskRefresh`) REQUIRES a fresher forterToken — never returns + * the same stale risk cookies as "success" (that caused false 408 recovery loops). */ async function captureViaCdp(opts: { port: number; @@ -433,16 +581,70 @@ async function captureViaCdp(opts: { arpSessionId: string; }> { let capturedAccessToken = ""; + let storageAccessToken = ""; let capturedArpSessionId = ""; let latestCookies: CdpCookie[] = []; - const pageSockets = new Map(); + /** Flatten auto-attach page sessions only — do NOT also open page WebSockets (double-attach freezes Chrome: "Debugger paused in another tab"). */ + const pageSessionIds = new Set(); let browserCdp: CdpSocket | null = null; - const initialForter = + let humanizeDone = false; + let riskReloadDone = false; + const requireFreshRisk = Boolean(opts.waitForRiskRefresh); + // Force warm: after wiping Firefly cookies, any fresh forter (ts within last 10 min) counts. + // Baseline from seed is only used for interactive partial-wait comparisons. + const seedForterTs = extractAdobeForterTimestampFromValue( [...(opts.seedBrowserCookies || []), ...parseCookieHeader(opts.seedCookie || "")].find( (cookie) => cookie.name.toLowerCase() === "fortertoken" - )?.value || ""; + )?.value || "" + ); + const baselineForterTs = requireFreshRisk ? 0 : Math.max(seedForterTs, 0); const startedAt = Date.now(); + const SPA_JWT_EXPR = `(() => { + const out = []; + try { + for (const key of Object.keys(sessionStorage)) { + if (!/adobeid_ims_access_token|clio-playground/i.test(key)) continue; + out.push(sessionStorage.getItem(key) || ""); + } + if (out.length === 0) { + for (const key of Object.keys(sessionStorage)) { + out.push(sessionStorage.getItem(key) || ""); + } + } + } catch (e) {} + return out.join("\\n"); + })()`; + + /** MUST be awaited before other session commands or Google OAuth freezes yellow. */ + const resumeTargetIfNeeded = async (sessionId: string): Promise => { + if (!browserCdp || !sessionId) return; + try { + await browserCdp.send("Runtime.runIfWaitingForDebugger", {}, sessionId); + } catch { + /* ignore */ + } + }; + + const setupPageSession = async (sessionId: string): Promise => { + if (!browserCdp || !sessionId || pageSessionIds.has(sessionId)) { + // Still resume if re-attached / re-entered waiting state. + await resumeTargetIfNeeded(sessionId); + return; + } + pageSessionIds.add(sessionId); + // Order is critical: resume FIRST, then enable domains (never leave waitingForDebugger). + await resumeTargetIfNeeded(sessionId); + await browserCdp.send("Network.enable", {}, sessionId).catch(() => undefined); + await resumeTargetIfNeeded(sessionId); + // Runtime.enable only for force-warm (sessionStorage/JWT evaluate). Interactive login + // primarily uses Network Authorization capture; Runtime is enabled on-demand when reading JWT. + if (requireFreshRisk) { + await browserCdp.send("Runtime.enable", {}, sessionId).catch(() => undefined); + await resumeTargetIfNeeded(sessionId); + } + }; + const onEvent = (method: string, params: Record) => { if (method === "Network.requestWillBeSent") { const request = params.request as @@ -461,24 +663,89 @@ async function captureViaCdp(opts: { } else if (method === "Target.attachedToTarget") { const sessionId = String(params.sessionId || ""); const targetInfo = params.targetInfo as { type?: string; targetId?: string } | undefined; - if (sessionId && targetInfo?.type === "page" && browserCdp) { - void browserCdp.send("Network.enable", {}, sessionId).catch(() => undefined); + if (!sessionId || !browserCdp) return; + if (targetInfo?.type === "page" || targetInfo?.type === "iframe") { + // Fire-and-forget async setup but resume is first awaited inside setupPageSession. + void setupPageSession(sessionId).catch(() => undefined); + } else { + void resumeTargetIfNeeded(sessionId).catch(() => undefined); } + } else if (method === "Target.detachedFromTarget") { + const sessionId = String(params.sessionId || ""); + if (sessionId) pageSessionIds.delete(sessionId); + } + }; + + const readSpaJwtFromSession = async (sessionId: string): Promise => { + if (!browserCdp || !sessionId) return ""; + try { + await resumeTargetIfNeeded(sessionId); + await browserCdp.send("Runtime.enable", {}, sessionId).catch(() => undefined); + await resumeTargetIfNeeded(sessionId); + const result = (await browserCdp.send( + "Runtime.evaluate", + { + expression: SPA_JWT_EXPR, + returnByValue: true, + awaitPromise: false, + }, + sessionId + )) as { result?: { value?: string } }; + return extractUserJwtFromStorageRaw(String(result?.result?.value || "")); + } catch { + return ""; + } + }; + + const nudgeForterSession = async (sessionId: string): Promise => { + if (!browserCdp || !sessionId) return; + try { + await resumeTargetIfNeeded(sessionId); + for (const [x, y] of [ + [140, 180], + [420, 260], + [700, 340], + [520, 420], + ] as const) { + await browserCdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y }, sessionId); + } + await browserCdp.send( + "Input.dispatchMouseEvent", + { type: "mousePressed", x: 640, y: 360, button: "left", clickCount: 1 }, + sessionId + ); + await browserCdp.send( + "Input.dispatchMouseEvent", + { type: "mouseReleased", x: 640, y: 360, button: "left", clickCount: 1 }, + sessionId + ); + await browserCdp.send( + "Input.dispatchMouseEvent", + { type: "mouseWheel", x: 400, y: 300, deltaX: 0, deltaY: 240 }, + sessionId + ); + } catch { + /* ignore */ } }; try { const browserWs = await openCdp(opts.browserWsUrl); browserCdp = new CdpSocket(browserWs, onEvent); - const seed: AdobeBrowserCookie[] = [ + // Force warm: never re-seed stale forter/arkose/sherlock — SSO cookies only. + // Interactive sign-in: seed nothing when freshSession emptied the jar; otherwise full seed ok. + const rawSeed: AdobeBrowserCookie[] = [ ...(opts.seedBrowserCookies || []), ...parseCookieHeader(opts.seedCookie || "").map((cookie) => ({ ...cookie, domain: "firefly.adobe.com", path: "/", - secure: true, + secure: true as const, })), ]; + const seed: AdobeBrowserCookie[] = ( + requireFreshRisk ? filterSeedCookiesForWarm(rawSeed, { dropRiskCookies: true }) : rawSeed + ) as AdobeBrowserCookie[]; if (seed.length > 0) { await browserCdp .send("Storage.setCookies", { @@ -497,50 +764,95 @@ async function captureViaCdp(opts: { }) .catch(() => undefined); } + // Force warm: wipe Firefly origin storage so Forter cannot re-hydrate a hours-old token + // from cookies/localStorage/IndexedDB. Keep adobelogin.com SSO (AdobeID) intact. + if (requireFreshRisk) { + try { + for (const origin of [ + "https://firefly.adobe.com", + "https://www.firefly.adobe.com", + "https://firefly-3p.ff.adobe.io", + ]) { + await browserCdp + .send("Storage.clearDataForOrigin", { + origin, + storageTypes: + "cookies,local_storage,indexeddb,cache_storage,service_workers,shader_cache", + }) + .catch(() => undefined); + } + const existing = (await browserCdp.send("Storage.getCookies")) as { + cookies?: CdpCookie[]; + }; + for (const cookie of existing?.cookies || []) { + const domain = String(cookie.domain || "") + .replace(/^\./, "") + .toLowerCase(); + const isFireflySite = + domain === "firefly.adobe.com" || + domain.endsWith(".firefly.adobe.com") || + domain === "ff.adobe.io" || + domain.endsWith(".ff.adobe.io"); + if (!isFireflySite && !isAdobeRiskCookieName(cookie.name)) continue; + if (domain.includes("adobelogin.com") && !isAdobeRiskCookieName(cookie.name)) continue; + await browserCdp + .send("Storage.deleteCookies", { + name: cookie.name, + ...(cookie.domain ? { domain: cookie.domain } : { url: FIREFLY_HOME_URL }), + path: cookie.path || "/", + }) + .catch(() => undefined); + } + } catch { + /* best-effort */ + } + } + // Single browser-level CDP + flatten auto-attach only. + // NEVER open /json/list page WebSockets (second debugger → yellow "Debugger paused"). await browserCdp.send("Target.setDiscoverTargets", { discover: true }).catch(() => undefined); await browserCdp .send("Target.setAutoAttach", { autoAttach: true, + // false = do not start targets paused; still resume defensively on attach. waitForDebuggerOnStart: false, flatten: true, }) .catch(() => undefined); + // Existing pages (Chrome already opened firefly URL) are NOT auto-attached as "new" + // targets — attach once via Target.attachToTarget (still one session, no page WS). + try { + const { targetInfos } = (await browserCdp.send("Target.getTargets")) as { + targetInfos?: Array<{ targetId?: string; type?: string; url?: string }>; + }; + for (const t of targetInfos || []) { + if ((t.type !== "page" && t.type !== "iframe") || !t.targetId) continue; + try { + const attached = (await browserCdp.send("Target.attachToTarget", { + targetId: t.targetId, + flatten: true, + })) as { sessionId?: string }; + const sid = String(attached?.sessionId || ""); + if (sid) await setupPageSession(sid); + } catch { + /* target may vanish */ + } + } + } catch { + /* getTargets may fail briefly */ + } + const deadline = Date.now() + opts.timeoutMs; + let lastJwtProbeAt = 0; + let lastResumeSweepAt = 0; while (Date.now() < deadline) { - // Attach to every page target listed by the DevTools HTTP API. - try { - const list = (await fetch(`http://127.0.0.1:${opts.port}/json/list`, { - signal: AbortSignal.timeout(2000), - }).then((r) => r.json())) as Array<{ - id?: string; - type?: string; - url?: string; - webSocketDebuggerUrl?: string; - }>; - for (const t of list) { - if (t.type !== "page" || !t.webSocketDebuggerUrl || !t.id) continue; - if (pageSockets.has(t.id)) continue; - try { - const ws = await openCdp(t.webSocketDebuggerUrl); - const cdp = new CdpSocket(ws, onEvent); - pageSockets.set(t.id, cdp); - await cdp.send("Network.enable"); - if ( - seed.length > 0 || - !t.url || - t.url === "about:blank" || - t.url.startsWith("chrome://") - ) { - await cdp.send("Page.enable").catch(() => undefined); - await cdp.send("Page.navigate", { url: FIREFLY_HOME_URL }).catch(() => undefined); - } - } catch { - // page may navigate away mid-connect - } + const now = Date.now(); + // Resume periodically (not every 400ms spam) — enough to clear accidental waits. + if (now - lastResumeSweepAt >= 1_500) { + lastResumeSweepAt = now; + for (const sid of [...pageSessionIds]) { + await resumeTargetIfNeeded(sid); } - } catch { - // list may fail briefly while Chrome starts } try { @@ -552,42 +864,136 @@ async function captureViaCdp(opts: { /* retry while Chrome is settling */ } + // Pull SPA sessionStorage JWT. Throttle evaluate so interactive Google login stays smooth + // (network Authorization capture is preferred and does not touch the page). + if (now - lastJwtProbeAt >= (requireFreshRisk ? 800 : 2_000)) { + lastJwtProbeAt = now; + for (const sid of pageSessionIds) { + const fromStorage = await readSpaJwtFromSession(sid); + if (fromStorage) { + storageAccessToken = fromStorage; + break; + } + } + } + + if (requireFreshRisk && pageSessionIds.size > 0) { + const elapsedWarm = Date.now() - startedAt; + // Nudge Forter early, then hard-reload once so SDKs re-mint risk tokens. + if (!humanizeDone && elapsedWarm >= 2_000) { + humanizeDone = true; + for (const sid of pageSessionIds) { + await nudgeForterSession(sid); + break; + } + } else if (!riskReloadDone && humanizeDone && elapsedWarm >= 12_000) { + riskReloadDone = true; + for (const sid of pageSessionIds) { + await browserCdp.send("Page.reload", { ignoreCache: true }, sid).catch(() => undefined); + await new Promise((r) => setTimeout(r, 1_500)); + await resumeTargetIfNeeded(sid); + await nudgeForterSession(sid); + break; + } + } + } + const fallbackToken = String(opts.fallbackAccessToken || "").trim(); const accessToken = - capturedAccessToken || (isAdobeUserAccessToken(fallbackToken) ? fallbackToken : ""); + capturedAccessToken || + storageAccessToken || + (isAdobeUserAccessToken(fallbackToken) ? fallbackToken : ""); if (accessToken) { - if (!opts.waitForRiskRefresh) { - return { - accessToken, - cookies: latestCookies, - arpSessionId: capturedArpSessionId, - }; - } const elapsed = Date.now() - startedAt; const forter = cookieValue(latestCookies, "forterToken"); + const forterTs = extractAdobeForterTimestampFromValue(forter); + const forterAgeMs = + forterTs > 0 ? Math.max(0, Date.now() - forterTs) : Number.POSITIVE_INFINITY; const hasRiskCookies = Boolean( forter && cookieValue(latestCookies, "ff_session_guid") && (cookieValue(latestCookies, "arkose") || cookieValue(latestCookies, "sherlockToken")) ); - const riskAdvanced = Boolean(forter && initialForter && forter !== initialForter); - if (hasRiskCookies && elapsed >= 8_000 && (riskAdvanced || elapsed >= 30_000)) { - return { - accessToken, - cookies: latestCookies, - arpSessionId: capturedArpSessionId, - }; + // Fresh forter: either newer than baseline, or mint age under 10 minutes (force wipe path). + const riskAdvanced = + forterTs > 0 && + (baselineForterTs <= 0 + ? forterAgeMs < 10 * 60_000 + : forterTs > baselineForterTs || forterAgeMs < 10 * 60_000); + + if (!requireFreshRisk) { + // Interactive Sign in: colligo 408s if we store JWT without forter/arkose/sherlock. + // Prefer a full risk cookie jar (browser works when these are present). Soft-wait + // up to 45s after JWT — WinUI login already allows minutes for OAuth. + if (hasRiskCookies && riskAdvanced) { + return { + accessToken, + cookies: latestCookies, + arpSessionId: capturedArpSessionId, + }; + } + // Last resort: JWT only after 45s (generate will likely 408 until risk cookies exist). + if (elapsed >= 45_000) { + return { + accessToken, + cookies: latestCookies, + arpSessionId: capturedArpSessionId, + }; + } + } else { + const minWaitMs = 8_000; + if (hasRiskCookies && elapsed >= minWaitMs && riskAdvanced) { + return { + accessToken, + cookies: latestCookies, + arpSessionId: capturedArpSessionId, + }; + } } } await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); } + // Force warm timed out without a fresher forter → hard fail (caller retries / surfaces error). + // IMPORTANT: baselineForterTs===0 must NOT accept any timestamped forter — require age < 10 min + // (or strictly newer than baseline). Old bug accepted 20h-old forter and colligo 408'd. + if (requireFreshRisk) { + const forter = cookieValue(latestCookies, "forterToken"); + const forterTs = extractAdobeForterTimestampFromValue(forter); + const forterAgeMs = + forterTs > 0 ? Math.max(0, Date.now() - forterTs) : Number.POSITIVE_INFINITY; + const riskAdvanced = + forterTs > 0 && + (baselineForterTs <= 0 + ? forterAgeMs < 10 * 60_000 + : forterTs > baselineForterTs || forterAgeMs < 10 * 60_000); + if (!riskAdvanced) { + throw new Error( + "Adobe Firefly risk session did not refresh (forterToken stale). " + + "Re-open Sign in with browser once, or wait and retry generate." + ); + } + const token = + capturedAccessToken || + storageAccessToken || + (isAdobeUserAccessToken(String(opts.fallbackAccessToken || "").trim()) + ? String(opts.fallbackAccessToken).trim() + : ""); + if (token) { + return { + accessToken: token, + cookies: latestCookies, + arpSessionId: capturedArpSessionId, + }; + } + } + const fallbackRaw = String(opts.fallbackAccessToken || "").trim(); const fallback = isAdobeUserAccessToken(fallbackRaw) ? fallbackRaw : ""; - if (fallback && latestCookies.length > 0) { + if (fallback && latestCookies.length > 0 && !requireFreshRisk) { return { - accessToken: capturedAccessToken || fallback, + accessToken: capturedAccessToken || storageAccessToken || fallback, cookies: latestCookies, arpSessionId: capturedArpSessionId, }; @@ -597,19 +1003,27 @@ async function captureViaCdp(opts: { "(open Generate) so the browser sends the Firefly request, then try again." ); } finally { - for (const cdp of pageSockets.values()) cdp.close(); + pageSessionIds.clear(); browserCdp?.close(); } } function killProcessTree(child: ChildProcess | null): void { if (!child?.pid) return; + const pid = child.pid; + // Never taskkill our own Node/pkg process or its parent (would kill the backend mid-login). + if (pid === process.pid || (typeof process.ppid === "number" && pid === process.ppid)) { + return; + } try { if (process.platform === "win32") { - spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { + // /T kills only this PID's descendants — not system Chrome profiles we did not spawn. + const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true, + detached: true, }); + killer.unref?.(); } else { child.kill("SIGTERM"); setTimeout(() => { @@ -629,27 +1043,73 @@ function killProcessTree(child: ChildProcess | null): void { } } +/** + * Background cookie/JWT refresh visibility. + * + * Default = **offscreen headed** (window parked off-display + minimized + windowsHide). + * True `--headless=new` mints Forter/ARP risk sessions colligo rejects → HTTP 408 on + * generate while a normal browser still works. Only opt into true headless with + * ADOBE_FIREFLY_CHROME_HEADLESS=1 (known-broken for media; debug only). + */ +export function adobeFireflyBackgroundUsesHeadlessChrome(): boolean { + return process.env.ADOBE_FIREFLY_CHROME_HEADLESS === "1"; +} + export function buildAdobeFireflyBrowserArgs(opts: { port: number; userDataDir: string; interactive: boolean; freshSession?: boolean; }): string[] { + const interactive = opts.interactive === true; + // Interactive "Sign in with browser" = real headed UI. Everything else = headless + // (or rare opt-in offscreen headed) so image gen / 408 recovery never pops a window. + const backgroundHeadless = !interactive && adobeFireflyBackgroundUsesHeadlessChrome(); + return [ `--remote-debugging-port=${opts.port}`, + // Force loopback bind so waitForCdpReady (node:http → 127.0.0.1) can connect. + "--remote-debugging-address=127.0.0.1", + // Chrome 111+ may refuse CDP HTTP (/json/version) without an allow-list. + "--remote-allow-origins=*", `--user-data-dir=${opts.userDataDir}`, "--no-first-run", "--no-default-browser-check", - ...(opts.interactive && opts.freshSession !== false ? ["--incognito"] : []), - ...(opts.interactive ? [] : ["--window-position=-32000,-32000"]), - "--window-size=1280,800", - opts.interactive ? FIREFLY_HOME_URL : "about:blank", + // NOTE: do NOT use --incognito here. Unique user-data-dir already isolates the + // session; incognito + remote-debugging is flaky on recent Chrome (CDP port + // never binds → ECONNREFUSED while a chrome.exe process still exists). + ...(interactive + ? [ + // Prevent attaching to an existing Chrome instance (would drop remote-debugging). + "--new-window", + "--window-size=1280,800", + ] + : backgroundHeadless + ? [ + // Silent cookie/JWT warm — ZERO visible window (user requirement). + "--headless=new", + "--disable-gpu", + "--window-size=1280,800", + ] + : [ + // Rare Forter debug: headed but parked far off-screen + minimized. + "--window-position=-32000,-32000", + "--window-size=1280,800", + "--start-minimized", + ]), + // Start on Firefly so risk SDKs load (especially important for background warm). + FIREFLY_HOME_URL, ]; } /** * Launch system Chrome/Edge at firefly.adobe.com, intercept firefly-3p * Authorization Bearer via CDP, return JWT + useful cookies. + * + * IMPORTANT: never mass-kill system Chrome via WMI/PowerShell from this path — + * that wedged the packaged backend event loop and made login show + * "VibeProxy backend is not ready for Adobe Firefly sign-in." + * Only kill the child we spawn (killProcessTree in finally / retry). */ async function runAdobeFireflyCdpBrowser(opts: { timeoutMs: number; @@ -674,70 +1134,149 @@ async function runAdobeFireflyCdpBrowser(opts: { let child: ChildProcess | null = null; try { const userDataDir = resolveAdobeFireflyBrowserProfileDir(opts.sessionKey); - const port = await getFreeLoopbackPort(); + // Isolate interactive sign-in profiles so a prior hung CDP instance cannot lock the dir. + // freshSession uses a per-attempt suffix; background warm keeps the stable key for SSO reuse. + const launchUserDataDir = + opts.interactive && opts.freshSession !== false + ? `${userDataDir}-login-${Date.now().toString(36)}` + : userDataDir; + try { + mkdirSync(launchUserDataDir, { recursive: true }); + } catch { + /* parent resolve already mkdir'd base */ + } - const args = buildAdobeFireflyBrowserArgs({ - port, - userDataDir, - interactive: opts.interactive, - freshSession: opts.freshSession, - }); - - child = spawn(browserPath, args, { - stdio: "ignore", - windowsHide: !opts.interactive, - detached: false, - }); - - // If Chrome exits immediately, fail fast with a clear message. - const earlyExit = new Promise((_, reject) => { - child?.once("exit", (code) => { - reject(new Error(`Browser exited early (code ${code}). Is the executable runnable?`)); - }); - child?.once("error", (err) => { - reject(new Error(`Failed to launch browser: ${err.message}`)); - }); - }); - - const ready = waitForCdpReady(port, CDP_READY_TIMEOUT_MS); - const { webSocketDebuggerUrl } = await Promise.race([ready, earlyExit]); - - // Detach exit handler so normal user close after capture is fine - child.removeAllListeners("exit"); - child.removeAllListeners("error"); - - const captured = await Promise.race([ - captureViaCdp({ + let lastError = "Browser failed to start"; + for (let launchAttempt = 1; launchAttempt <= 2; launchAttempt++) { + if (child) { + killProcessTree(child); + child = null; + await new Promise((r) => setTimeout(r, 300)); + } + const port = await getFreeLoopbackPort(); + // Unique profile per launch attempt so a half-dead previous Chrome cannot lock the dir. + const attemptUserDataDir = + opts.interactive && opts.freshSession !== false + ? `${launchUserDataDir}-a${launchAttempt}` + : launchUserDataDir; + try { + mkdirSync(attemptUserDataDir, { recursive: true }); + } catch { + /* best-effort */ + } + const args = buildAdobeFireflyBrowserArgs({ port, - browserWsUrl: webSocketDebuggerUrl, - timeoutMs: opts.timeoutMs, - fallbackAccessToken: opts.accessToken, - seedCookie: opts.seedCookie, - seedBrowserCookies: - opts.interactive && opts.freshSession !== false - ? [] - : loadAdobeBrowserCookies(opts.sessionKey), - waitForRiskRefresh: !opts.interactive, - }), - earlyExit, - ]); + userDataDir: attemptUserDataDir, + interactive: opts.interactive, + freshSession: opts.freshSession, + }); - const cookie = buildAdobeFireflyCookieHeader(captured.cookies); - saveAdobeBrowserCookies(opts.sessionKey, captured.cookies); - const account = await resolveAdobeAccountLabel(captured.accessToken); - opts.log?.info?.( - "ADOBE-FIREFLY", - `CDP ${opts.interactive ? "sign-in" : "refresh"} captured durable session ` + - `(cookieCount=${captured.cookies.length}, arpLen=${captured.arpSessionId.length})` - ); + // Interactive: keep attached (reliable CDP bind on Windows). Background warm may + // detach so a long Forter wait does not pin the Node process refcount. + // Host job SILENT_BREAKAWAY_OK still prevents Chrome from joining the backend job + // (that was killing/wedging VibeProxyServices on Sign in with browser). + child = spawn(browserPath, args, { + stdio: "ignore", + // Interactive sign-in: show Chrome. Background warm: hide spawn console/window + // host; headless flags already suppress the browser UI. + windowsHide: !opts.interactive, + detached: !opts.interactive, + }); + if (!opts.interactive) { + try { + child.unref?.(); + } catch { + /* ignore */ + } + } + + let exitedEarly = false; + let exitCode: number | null = null; + const onExit = (code: number | null) => { + exitedEarly = true; + exitCode = code; + }; + const onErr = (err: Error) => { + exitedEarly = true; + lastError = `Failed to launch browser: ${err.message}`; + }; + // Attach listeners BEFORE any delay so we never miss a fast exit. + child.once("exit", onExit); + child.once("error", onErr); + // Give Chrome a beat to bind --remote-debugging-port before the first CDP probe. + await new Promise((r) => setTimeout(r, 600)); + if (exitedEarly) { + lastError = `Browser exited early (code ${exitCode}). Retrying…`; + opts.log?.warn?.("ADOBE-FIREFLY", lastError); + continue; + } + + try { + const cdpWaitMs = launchAttempt === 1 ? CDP_READY_TIMEOUT_MS : CDP_READY_TIMEOUT_RETRY_MS; + const { webSocketDebuggerUrl } = await waitForCdpReady(port, cdpWaitMs); + if (exitedEarly) { + lastError = `Browser exited early (code ${exitCode}). Retrying…`; + continue; + } + child.removeListener("exit", onExit); + child.removeListener("error", onErr); + + opts.log?.info?.( + "ADOBE-FIREFLY", + opts.interactive + ? "Chrome ready — complete Adobe/Google sign-in in the window (do not close it)" + : "headless CDP warm attached" + ); + + // Interactive: capture JWT as soon as firefly-3p auth is seen. Soft-wait for risk + // cookies is handled inside captureViaCdp; do NOT force risk refresh for interactive + // (that blocked login when Forter did not advance). + const captured = await captureViaCdp({ + port, + browserWsUrl: webSocketDebuggerUrl, + timeoutMs: opts.timeoutMs, + fallbackAccessToken: opts.accessToken, + seedCookie: opts.seedCookie, + seedBrowserCookies: + opts.interactive && opts.freshSession !== false + ? [] + : loadAdobeBrowserCookies(opts.sessionKey), + waitForRiskRefresh: !opts.interactive, + }); + + const cookie = buildAdobeFireflyCookieHeader(captured.cookies); + // Persist risk cookies under the stable session key (not the -login- temp dir). + saveAdobeBrowserCookies(opts.sessionKey, captured.cookies); + const account = await resolveAdobeAccountLabel(captured.accessToken); + opts.log?.info?.( + "ADOBE-FIREFLY", + `CDP ${opts.interactive ? "sign-in" : "refresh"} captured durable session ` + + `(cookieCount=${captured.cookies.length}, arpLen=${captured.arpSessionId.length})` + ); + return { + success: true, + credentials: { + accessToken: captured.accessToken, + ...(cookie ? { cookie } : {}), + }, + ...(captured.arpSessionId ? { arpSessionId: captured.arpSessionId } : {}), + ...(account ? { account } : {}), + }; + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + if (launchAttempt < 2) { + opts.log?.warn?.( + "ADOBE-FIREFLY", + `Chrome CDP launch attempt ${launchAttempt} failed: ${lastError}; retrying…` + ); + continue; + } + break; + } + } return { - success: true, - credentials: { - accessToken: captured.accessToken, - ...(cookie ? { cookie } : {}), - }, - ...(captured.arpSessionId ? { arpSessionId: captured.arpSessionId } : {}), - ...(account ? { account } : {}), + success: false, + error: sanitizeErrorMessage(lastError), }; } catch (error) { return { @@ -745,6 +1284,9 @@ async function runAdobeFireflyCdpBrowser(opts: { error: sanitizeErrorMessage(error instanceof Error ? error.message : error), }; } finally { + // Interactive sign-in: leave the window open briefly is not possible after return — + // we must kill the CDP-debug Chrome we spawned (it is a dedicated profile instance). + // Only our child PID tree is killed — never a system-wide Chrome sweep. killProcessTree(child); child = null; } @@ -754,7 +1296,9 @@ export async function startAdobeFireflyBrowserLogin( requestedTimeout?: unknown, opts?: { sessionKey?: string; freshSession?: boolean } ): Promise { - const run = cdpBrowserChain.then(() => + // Interactive queue is independent of background warm — long 408 recovery must not + // prevent "Sign in with browser" from launching Chrome. + const run = interactiveCdpChain.then(() => runAdobeFireflyCdpBrowser({ timeoutMs: clampAdobeFireflyLoginTimeout(requestedTimeout), interactive: true, @@ -762,7 +1306,7 @@ export async function startAdobeFireflyBrowserLogin( freshSession: opts?.freshSession !== false, }) ); - cdpBrowserChain = run.then( + interactiveCdpChain = run.then( () => undefined, () => undefined ); @@ -777,7 +1321,7 @@ export async function refreshAdobeFireflyViaCdp(opts: { log?: AdobeFireflyBrowserLog; sessionKey?: string; }): Promise { - const run = cdpBrowserChain.then(async () => { + const run = backgroundCdpChain.then(async () => { const result = await runAdobeFireflyCdpBrowser({ timeoutMs: Math.max(15_000, Math.min(120_000, Number(opts.timeoutMs) || 75_000)), interactive: false, @@ -788,14 +1332,20 @@ export async function refreshAdobeFireflyViaCdp(opts: { }); const accessToken = String(result.credentials?.accessToken || "").trim(); const cookie = String(result.credentials?.cookie || "").trim(); - if (!result.success || !accessToken || !cookie) return null; + if (!result.success || !accessToken || !cookie) { + opts.log?.warn?.( + "ADOBE-FIREFLY", + `CDP background refresh incomplete: ${result.error || "missing token/cookie"}` + ); + return null; + } return { accessToken, cookie, arpSessionId: String(result.arpSessionId || "").trim(), }; }); - cdpBrowserChain = run.then( + backgroundCdpChain = run.then( () => undefined, () => undefined ); diff --git a/open-sse/services/adobeFireflyChromeRuntime.ts b/open-sse/services/adobeFireflyChromeRuntime.ts new file mode 100644 index 0000000000..c9af5ecfcf --- /dev/null +++ b/open-sse/services/adobeFireflyChromeRuntime.ts @@ -0,0 +1,1200 @@ +/** + * Adobe Firefly optional Chrome (CDP) session runtime. + * + * Default product path is the same as other OmniRoute web-cookie providers + * (notion-web, perplexity-web, …): pure HTTP with the pasted Cookie/JWT — NO browser. + * + * Browser warm is OPT-IN for proactive use (`ADOBE_FIREFLY_BROWSER_REFRESH=1`) and may + * also run mid-batch 408 recovery via `allowWithoutEnvOptIn`. + * + * **Mode (UI + colligo):** background warm defaults to **offscreen headed** (parked off + * display + minimized) so Forter tokens work. True `--headless=new` is opt-in only + * (`ADOBE_FIREFLY_CHROME_HEADLESS=1`) and typically yields generate HTTP 408 while a real + * browser still works. Interactive sign-in uses modeOverride=visible. + */ + +import { spawn, type ChildProcess } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + buildAdobeArpSessionIdFromCookies, + extractAdobeForterTimestampMs, + mergeAdobeCookieHeaders, + type AdobeFireflySession, +} from "./adobeFireflySession.ts"; +import { + extractAdobeCookieHeader, + isAdobeUserAccessToken, + looksLikeAdobeJwt, + decodeAdobeJwtPayload, +} from "./adobeFireflyClient.ts"; + +const DEFAULT_CDP_PORT = Number(process.env.ADOBE_FIREFLY_CHROME_CDP_PORT || 9334); +const PROFILE_DIR_NAME = "adobe-chrome-profile"; + +type Log = { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void }; + +type RuntimeState = { + port: number; + profileDir: string; + chromeProc: ChildProcess | null; + browser: import("playwright").Browser | null; + context: import("playwright").BrowserContext | null; + page: import("playwright").Page | null; + lastWarmAt: number; + lastCookieSeed: string; + /** "offscreen" | "visible" | "headless" */ + mode: string; +}; + +let runtime: RuntimeState | null = null; +let warmChain: Promise = Promise.resolve(); +let startingChrome: Promise | null = null; +/** Temporary mode override (e.g. force a visible window for interactive sign-in). */ +let modeOverride: "offscreen" | "visible" | "headless" | null = null; + +/** + * Background cookie/JWT work should not flash a normal desktop window. + * - default / HEADED / OFFSCREEN → offscreen headed (Forter-safe; colligo accepts) + * - HEADLESS=1 → true headless (often 408 on generate — debug only) + * - VISIBLE=1 → on-screen (debug only; interactive sign-in uses modeOverride) + */ +function resolveChromeMode(): "offscreen" | "visible" | "headless" { + if (modeOverride) return modeOverride; + if (process.env.ADOBE_FIREFLY_CHROME_VISIBLE === "1") return "visible"; + // True headless is opt-in only — colligo rejects its Forter tokens (API 408, browser OK). + if (process.env.ADOBE_FIREFLY_CHROME_HEADLESS === "1") return "headless"; + return "offscreen"; +} + +async function safePageWait(page: import("playwright").Page, ms: number): Promise { + try { + if (page.isClosed()) return; + await page.waitForTimeout(ms); + } catch { + /* page closed / target destroyed — caller will re-acquire */ + } +} + +async function ensureLivePage( + context: import("playwright").BrowserContext, + preferred: import("playwright").Page | null +): Promise { + if (preferred && !preferred.isClosed()) { + try { + // Touch the page; if target is dead this throws + void preferred.url(); + return preferred; + } catch { + /* fall through */ + } + } + const existing = + context.pages().find((p) => !p.isClosed() && /firefly\.adobe\.com/i.test(p.url())) || + context.pages().find((p) => !p.isClosed()); + if (existing) return existing; + return context.newPage(); +} + +function dataDir(): string { + return ( + String(process.env.DATA_DIR || process.env.OMNIROUTE_DATA_DIR || "").trim() || + join(process.cwd(), ".data") + ); +} + +function profileDir(): string { + // Prefer LOCALAPPDATA when present so the managed Chrome profile survives restarts. + const local = process.env.LOCALAPPDATA || process.env.HOME || process.env.USERPROFILE || ""; + if (local) { + const p = join(local, "OmniRoute", PROFILE_DIR_NAME); + try { + mkdirSync(p, { recursive: true }); + } catch { + /* ignore */ + } + return p; + } + const p = join(dataDir(), PROFILE_DIR_NAME); + try { + mkdirSync(p, { recursive: true }); + } catch { + /* ignore */ + } + return p; +} + +function findChromeExecutable(): string | null { + if (process.env.CHROME_PATH && existsSync(process.env.CHROME_PATH)) { + return process.env.CHROME_PATH; + } + const candidates = [ + "C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe", + "C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe", + join(process.env.LOCALAPPDATA || "", "Google", "Chrome", "Application", "chrome.exe"), + "/usr/bin/google-chrome", + "/usr/bin/chromium-browser", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + ]; + for (const c of candidates) { + if (c && existsSync(c)) return c; + } + return null; +} + +async function waitForCdp(port: number, timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const r = await fetch(`http://127.0.0.1:${port}/json/version`); + if (r.ok) return; + } catch { + /* retry */ + } + await new Promise((r) => setTimeout(r, 350)); + } + throw new Error(`Chrome CDP not ready on port ${port}`); +} + +async function killPortOwner(port: number): Promise { + if (process.platform !== "win32") return; + try { + const { execSync } = await import("node:child_process"); + execSync( + `powershell -NoProfile -Command "Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }"`, + { stdio: "ignore", timeout: 8000 } + ); + } catch { + /* ignore */ + } +} + +function parseCookieHeader(cookieHeader: string): Array<{ name: string; value: string }> { + const out: Array<{ name: string; value: string }> = []; + for (const part of String(cookieHeader || "").split(";")) { + const idx = part.indexOf("="); + if (idx <= 0) continue; + let name = part.slice(0, idx).trim(); + let value = part.slice(idx + 1).trim(); + try { + name = decodeURIComponent(name); + } catch { + /* keep */ + } + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (!name || /[\r\n\0]/.test(value)) continue; + out.push({ name, value }); + } + return out; +} + +/** Detect whether the process listening on `port` was started with --headless. */ +async function isPortChromeHeadless(port: number): Promise { + if (process.platform !== "win32") return null; + try { + const { execSync } = await import("node:child_process"); + const out = execSync( + `powershell -NoProfile -Command "$c=Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if(-not $c){exit 2}; $p=Get-CimInstance Win32_Process -Filter (\\"ProcessId=$($c.OwningProcess)\\"); if($p.CommandLine -match 'headless'){Write-Output 'headless'}else{Write-Output 'headed'}"`, + { encoding: "utf8", timeout: 8000, stdio: ["ignore", "pipe", "ignore"] } + ).trim(); + if (out === "headless") return true; + if (out === "headed") return false; + return null; + } catch { + return null; + } +} + +async function tryConnectExistingCdp( + chromium: typeof import("playwright").chromium, + port: number, + dir: string, + desiredMode: string, + log?: Log +): Promise { + try { + const r = await fetch(`http://127.0.0.1:${port}/json/version`); + if (!r.ok) return null; + + // Match process headless-ness to desiredMode: + // - headless desired: never reuse a headed process (would flash a real window). + // - offscreen/visible desired: never reuse headless (wrong Forter/profile mode). + const headless = await isPortChromeHeadless(port); + if (desiredMode === "headless" && headless === false) { + log?.warn?.( + "ADOBE-FIREFLY", + `existing CDP on ${port} is headed — killing and restarting as headless (no UI)` + ); + await killPortOwner(port); + return null; + } + if (desiredMode !== "headless" && headless === true) { + log?.warn?.( + "ADOBE-FIREFLY", + `existing CDP on ${port} is headless — killing and restarting as ${desiredMode}` + ); + await killPortOwner(port); + return null; + } + + const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`); + const context = browser.contexts()[0] || (await browser.newContext()); + const page = await ensureLivePage(context, null); + log?.info?.( + "ADOBE-FIREFLY", + `reused existing Chrome CDP port=${port} desiredMode=${desiredMode} pages=${context.pages().length}` + ); + return { + port, + profileDir: dir, + chromeProc: null, + browser, + context, + page, + lastWarmAt: 0, + lastCookieSeed: "", + mode: desiredMode, + }; + } catch { + return null; + } +} + +/** + * Chrome remembers last window bounds in the profile. Off-screen warms park the window at + * ~(-32000,-32000) / secondary-monitor coords — a later "visible" sign-in then opens Firefly + * off-screen and the user sees nothing. Reset placement on disk before a visible spawn. + */ +function resetChromeWindowPlacementOnDisk(dir: string, log?: Log): void { + const candidates = [join(dir, "Default", "Preferences"), join(dir, "Preferences")]; + const onScreen = { + bottom: 960, + left: 80, + maximized: false, + right: 1360, + top: 60, + work_area_bottom: 1080, + work_area_left: 0, + work_area_right: 1920, + work_area_top: 0, + }; + for (const path of candidates) { + if (!existsSync(path)) continue; + try { + const raw = readFileSync(path, "utf8"); + const obj = JSON.parse(raw) as Record; + const browser = ( + obj.browser && typeof obj.browser === "object" + ? (obj.browser as Record) + : {} + ) as Record; + browser.window_placement = onScreen; + browser.window_placement_popup = onScreen; + obj.browser = browser; + // Avoid session restore putting us back off-screen. + if (obj.profile && typeof obj.profile === "object") { + (obj.profile as Record).exit_type = "Normal"; + (obj.profile as Record).exited_cleanly = true; + } + writeFileSync(path, JSON.stringify(obj), "utf8"); + log?.info?.("ADOBE-FIREFLY", `reset Chrome window_placement on disk (${path})`); + } catch (err) { + log?.warn?.( + "ADOBE-FIREFLY", + `could not reset window_placement: ${err instanceof Error ? err.message : String(err)}` + ); + } + } +} + +/** After CDP connect, force the browser window onto the primary work area (visible sign-in). */ +async function forceChromeWindowOnScreen( + browser: import("playwright").Browser, + page: import("playwright").Page, + log?: Log +): Promise { + try { + const cdp = await page.context().newCDPSession(page); + const { windowId } = (await cdp.send( + "Browser.getWindowForTarget" as "Browser.getWindowForTarget" + )) as { + windowId: number; + }; + await cdp.send("Browser.setWindowBounds" as "Browser.setWindowBounds", { + windowId, + bounds: { + left: 80, + top: 60, + width: 1280, + height: 900, + windowState: "normal", + }, + }); + await page.bringToFront().catch(() => {}); + // Best-effort Windows focus (Chrome can open behind the host app). + if (process.platform === "win32") { + try { + const { execSync } = await import("node:child_process"); + execSync( + `powershell -NoProfile -Command "$p=Get-Process chrome -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowTitle -match 'Firefly|Adobe|Chrome' } | Select-Object -First 1; if($p){ Add-Type -Name W -Namespace N -MemberDefinition '[DllImport(\\\"user32.dll\\\")] public static extern bool SetForegroundWindow(IntPtr h); [DllImport(\\\"user32.dll\\\")] public static extern bool ShowWindow(IntPtr h,int n);'; [N.W]::ShowWindow($p.MainWindowHandle,9) | Out-Null; [N.W]::SetForegroundWindow($p.MainWindowHandle) | Out-Null }"`, + { stdio: "ignore", timeout: 5000 } + ); + } catch { + /* ignore */ + } + } + log?.info?.("ADOBE-FIREFLY", "forced Chrome window on-screen (80,60 1280x900)"); + } catch (err) { + log?.warn?.( + "ADOBE-FIREFLY", + `forceChromeWindowOnScreen failed: ${err instanceof Error ? err.message : String(err)}` + ); + } +} + +async function ensureChromeStarted( + log?: Log, + opts?: { forceRestart?: boolean } +): Promise { + const mode = resolveChromeMode(); + + // Always kill the CDP port on forceRestart (even if in-memory runtime is null — leftover + // off-screen Chrome from a prior warm is the usual "browser didn't appear" case). + if (opts?.forceRestart) { + try { + await runtime?.browser?.close(); + } catch { + /* ignore */ + } + runtime = null; + await killPortOwner(DEFAULT_CDP_PORT); + } + + if (runtime?.browser && runtime.context) { + // Mode mismatch: always restart so we never keep a headed UI when silent headless + // is required, and never keep headless when offscreen/visible is required. + if (runtime.mode !== mode) { + log?.warn?.( + "ADOBE-FIREFLY", + `cached Chrome mode=${runtime.mode} desired=${mode} — restarting` + ); + try { + await runtime.browser?.close(); + } catch { + /* ignore */ + } + runtime = null; + await killPortOwner(DEFAULT_CDP_PORT); + } else { + try { + await fetch(`http://127.0.0.1:${runtime.port}/json/version`); + // Live process must still match headless/headed expectation. + const hl = await isPortChromeHeadless(runtime.port); + const mismatch = + (mode === "headless" && hl === false) || (mode !== "headless" && hl === true); + if (mismatch) { + log?.warn?.("ADOBE-FIREFLY", `live CDP headless=${hl} desired=${mode} — restarting`); + try { + await runtime.browser?.close(); + } catch { + /* ignore */ + } + runtime = null; + await killPortOwner(DEFAULT_CDP_PORT); + } else { + runtime.page = await ensureLivePage(runtime.context, runtime.page); + return runtime; + } + } catch { + try { + await runtime.browser?.close(); + } catch { + /* ignore */ + } + runtime = null; + } + } + } + + if (startingChrome) return startingChrome; + + if (process.env.ADOBE_FIREFLY_BROWSER_REFRESH === "0") { + throw new Error("ADOBE_FIREFLY_BROWSER_REFRESH=0"); + } + + startingChrome = (async () => { + const chromePath = findChromeExecutable(); + if (!chromePath) throw new Error("Google Chrome not found (set CHROME_PATH)"); + + let chromium: typeof import("playwright").chromium; + try { + chromium = (await import("playwright")).chromium; + } catch { + throw new Error("playwright package not available for CDP connect"); + } + + const port = DEFAULT_CDP_PORT; + const dir = profileDir(); + + // Prefer reusing a healthy CDP only when mode matches (headless vs headed). + // Mismatched reuse is rejected inside tryConnectExistingCdp. + if (!opts?.forceRestart) { + const existing = await tryConnectExistingCdp(chromium, port, dir, mode, log); + if (existing) { + runtime = existing; + return existing; + } + } + + // Kill stale listener before spawn (headless leftover / force restart). + await killPortOwner(port); + + // Visible sign-in: wipe off-screen bounds left by prior off-screen warms. + if (mode === "visible") { + resetChromeWindowPlacementOnDisk(dir, log); + } + + // Default headless: zero UI for cookie/JWT warm. Offscreen/visible are opt-in only. + const args = [ + `--remote-debugging-port=${port}`, + "--remote-debugging-address=127.0.0.1", + "--remote-allow-origins=*", + `--user-data-dir=${dir}`, + "--no-first-run", + "--no-default-browser-check", + "--disable-blink-features=AutomationControlled", + "--disable-features=TranslateUI", + "--disable-session-crashed-bubble", + "--hide-crash-restore-bubble", + ...(mode === "headless" + ? ["--headless=new", "--disable-gpu", "--window-size=1280,900"] + : mode === "offscreen" + ? [ + "--window-position=-32000,-32000", + "--window-size=1280,900", + // Start minimized as extra belt-and-suspenders (Windows may still create a taskbar entry). + "--start-minimized", + ] + : [ + // Explicit on-screen position — profile restore alone is not enough. + "--window-position=80,60", + "--window-size=1280,900", + "--start-maximized", + ]), + mode === "visible" + ? "https://firefly.adobe.com/" + : "https://firefly.adobe.com/generate/image", + ]; + + log?.info?.( + "ADOBE-FIREFLY", + `starting Chrome CDP profile=${dir} port=${port} mode=${mode} (headless=silent; offscreen=headed parked; visible=on-screen sign-in)` + ); + const chromeProc = spawn(chromePath, args, { + stdio: "ignore", + detached: true, + // Only interactive sign-in may show a window host; silent refresh stays hidden. + windowsHide: mode !== "visible", + }); + chromeProc.unref(); + + await waitForCdp(port, 45_000); + const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`); + const context = browser.contexts()[0] || (await browser.newContext()); + const page = await ensureLivePage(context, null); + + if (mode === "visible") { + await forceChromeWindowOnScreen(browser, page, log); + } + + runtime = { + port, + profileDir: dir, + chromeProc, + browser, + context, + page, + lastWarmAt: 0, + lastCookieSeed: "", + mode, + }; + return runtime; + })(); + + try { + return await startingChrome; + } finally { + startingChrome = null; + } +} + +async function seedCookies( + context: import("playwright").BrowserContext, + cookieHeader: string +): Promise { + const pairs = parseCookieHeader(cookieHeader); + let n = 0; + for (const { name, value } of pairs) { + for (const domain of [".adobe.com", "firefly.adobe.com", ".firefly.adobe.com"]) { + try { + await context.addCookies([ + { name, value, domain, path: "/", secure: true, sameSite: "Lax" }, + ]); + n++; + break; + } catch { + /* try next domain */ + } + } + } + return n; +} + +function extractUserJwtFromStorageRaw(raw: string): string { + const matches = + String(raw || "").match(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g) || []; + for (const tok of matches) { + if (looksLikeAdobeJwt(tok) && isAdobeUserAccessToken(tok)) return tok; + } + return ""; +} + +async function readSpaUserJwt(page: import("playwright").Page): Promise { + const tokens = await page.evaluate(() => { + const out: string[] = []; + for (const key of Object.keys(sessionStorage)) { + if (!/adobeid_ims_access_token|clio-playground/i.test(key)) continue; + out.push(sessionStorage.getItem(key) || ""); + } + return out; + }); + for (const raw of tokens) { + const tok = extractUserJwtFromStorageRaw(raw); + if (tok) return tok; + } + // broader scan + const all = await page.evaluate(() => { + const out: string[] = []; + for (const key of Object.keys(sessionStorage)) out.push(sessionStorage.getItem(key) || ""); + return out; + }); + for (const raw of all) { + const tok = extractUserJwtFromStorageRaw(raw); + if (tok) return tok; + } + return ""; +} + +async function injectUserJwt(page: import("playwright").Page, token: string): Promise { + if (!token) return; + await page + .evaluate((t) => { + for (const key of Object.keys(sessionStorage)) { + if (!key.includes("adobeid_ims_access_token")) continue; + try { + const obj = JSON.parse(sessionStorage.getItem(key) || "{}") as Record; + obj.tokenValue = t; + obj.access_token = t; + obj.valid = true; + obj.expire = Date.now() + 20 * 3600 * 1000; + obj.expires_in = 86400000; + obj.client_id = "clio-playground-web"; + sessionStorage.setItem(key, JSON.stringify(obj)); + } catch { + /* skip */ + } + } + }, token) + .catch(() => {}); +} + +async function humanize(page: import("playwright").Page): Promise { + try { + if (page.isClosed()) return; + for (let i = 0; i < 16; i++) { + if (page.isClosed()) return; + await page.mouse.move(100 + i * 45, 160 + (i % 5) * 35, { steps: 4 }); + await safePageWait(page, 80); + } + // Light scroll nudges Forter / passive listeners on real headed Chrome. + await page.mouse.wheel(0, 240).catch(() => {}); + await safePageWait(page, 200); + await page.mouse.wheel(0, -120).catch(() => {}); + } catch { + /* ignore */ + } +} + +/** Poll jar until forterToken timestamp advances past `minTs`, or timeout. */ +async function waitForFresherForter( + context: import("playwright").BrowserContext, + minTs: number, + timeoutMs: number, + log?: Log +): Promise { + const start = Date.now(); + let best = 0; + while (Date.now() - start < timeoutMs) { + const cookie = await jarCookieHeader(context); + const ts = extractAdobeForterTimestampMs(cookie); + if (ts > best) best = ts; + if (ts > minTs) { + log?.info?.("ADOBE-FIREFLY", `Chrome forter refreshed (ts=${ts}, deltaMs=${ts - minTs})`); + return ts; + } + await new Promise((r) => setTimeout(r, 1500)); + } + log?.warn?.( + "ADOBE-FIREFLY", + `Chrome forter did not advance past ${minTs} within ${timeoutMs}ms (best=${best})` + ); + return best; +} + +async function jarCookieHeader(context: import("playwright").BrowserContext): Promise { + const jar = await context.cookies(); + // Prefer firefly-relevant cookies; keep full jar for rebuild pieces + return jar.map((c) => `${c.name}=${c.value}`).join("; "); +} + +async function buildArpFromContext( + context: import("playwright").BrowserContext, + page: import("playwright").Page +): Promise<{ arp: string; cookie: string }> { + const cookie = await jarCookieHeader(context); + const ls = await page + .evaluate(() => ({ + bfp: localStorage.getItem("bfp") || "", + fpjs: localStorage.getItem("fpjs") || "", + })) + .catch(() => ({ bfp: "", fpjs: "" })); + let blob = cookie; + if (ls.bfp && !/(?:^|;\s*)bfp=/.test(blob)) blob = mergeAdobeCookieHeaders(blob, `bfp=${ls.bfp}`); + if (ls.fpjs && !/(?:^|;\s*)fpjs=/.test(blob)) { + blob = mergeAdobeCookieHeaders(blob, `fpjs=${encodeURIComponent(ls.fpjs)}`); + } + const arp = + buildAdobeArpSessionIdFromCookies(blob, { + bfp: ls.bfp || undefined, + fpjs: ls.fpjs || undefined, + }) || ""; + return { arp, cookie: extractAdobeCookieHeader(blob) || blob }; +} + +/** + * Warm (or create) the durable Chrome Firefly session. + * Returns accessToken + cookie + arpSessionId ready for generate-async. + */ +export async function warmAdobeFireflyViaChrome(opts: { + cookie: string; + accessToken?: string; + log?: Log; + /** Wait for interactive login if only guest JWT is present (ms, 0 = don't wait). */ + waitForLoginMs?: number; + /** + * Mid-batch 408 recovery: allow warm without ADOBE_FIREFLY_BROWSER_REFRESH=1. + * Uses headless Chrome by default (no UI). Opt into headed offscreen with + * ADOBE_FIREFLY_CHROME_HEADED=1 if diagnosing colligo. + */ + allowWithoutEnvOptIn?: boolean; + /** When true (or ADOBE_FIREFLY_CHROME_PING=1), prove ARP with in-page generate-async. */ + proveWithPing?: boolean; +}): Promise { + // Kill switch + if (process.env.ADOBE_FIREFLY_BROWSER_REFRESH === "0") return null; + // Default OFF for proactive use; recovery may pass allowWithoutEnvOptIn. + if (!opts.allowWithoutEnvOptIn && process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "1") return null; + if (process.env.NODE_ENV === "test" || process.env.VITEST || process.env.NODE_TEST_CONTEXT) { + return null; + } + + const run = warmChain.then(async () => { + const log = opts.log; + const cookieIn = extractAdobeCookieHeader(opts.cookie) || opts.cookie; + if (!cookieIn?.trim() && !opts.accessToken) return null; + + const forterBefore = extractAdobeForterTimestampMs(cookieIn); + // Force restart on recovery so we never reuse a half-dead CDP; mode is still headless + // by default (no popup). ADOBE_FIREFLY_CHROME_HEADED=1 opts into offscreen headed. + const rt = await ensureChromeStarted(log, { + forceRestart: + Boolean(opts.allowWithoutEnvOptIn) || + process.env.ADOBE_FIREFLY_CHROME_FORCE_RESTART === "1", + }); + const context = rt.context!; + let page = await ensureLivePage(context, rt.page); + + if (cookieIn && cookieIn !== rt.lastCookieSeed) { + const n = await seedCookies(context, cookieIn); + rt.lastCookieSeed = cookieIn; + log?.info?.("ADOBE-FIREFLY", `Chrome seeded ${n} cookie entries`); + } + + // Navigate / reload with page-closed recovery (prior flaky "Target page closed"). + const gotoFirefly = async () => { + page = await ensureLivePage(context, page); + if (!/firefly\.adobe\.com/i.test(page.url())) { + await page.goto("https://firefly.adobe.com/generate/image", { + waitUntil: "domcontentloaded", + timeout: 90_000, + }); + } else { + await page.reload({ waitUntil: "domcontentloaded", timeout: 90_000 }).catch(async () => { + page = await ensureLivePage(context, null); + await page.goto("https://firefly.adobe.com/generate/image", { + waitUntil: "domcontentloaded", + timeout: 90_000, + }); + }); + } + }; + + await gotoFirefly(); + await safePageWait(page, 8_000); + await humanize(page); + + let jwt = await readSpaUserJwt(page).catch(() => ""); + if (!jwt && opts.accessToken && isAdobeUserAccessToken(opts.accessToken)) { + page = await ensureLivePage(context, page); + await injectUserJwt(page, opts.accessToken); + await page.reload({ waitUntil: "domcontentloaded", timeout: 90_000 }).catch(() => {}); + await safePageWait(page, 6_000); + await humanize(page); + jwt = (await readSpaUserJwt(page).catch(() => "")) || opts.accessToken; + log?.info?.("ADOBE-FIREFLY", "Chrome injected cached user JWT into SPA sessionStorage"); + } + + // Wait for interactive login if still no user JWT (one-time profile SSO) + const waitMs = opts.waitForLoginMs ?? Number(process.env.ADOBE_FIREFLY_LOGIN_WAIT_MS || 0); + if (!jwt && waitMs > 0) { + log?.warn?.( + "ADOBE-FIREFLY", + `No user JWT yet — sign in to Firefly in the Chrome window (wait ${Math.round(waitMs / 1000)}s)` + ); + const start = Date.now(); + while (Date.now() - start < waitMs) { + await safePageWait(page, 2000); + page = await ensureLivePage(context, page); + jwt = await readSpaUserJwt(page).catch(() => ""); + if (jwt) break; + } + } + + if (!jwt && opts.accessToken && isAdobeUserAccessToken(opts.accessToken)) { + jwt = opts.accessToken; + } + if (!jwt || !isAdobeUserAccessToken(jwt)) { + log?.warn?.("ADOBE-FIREFLY", "Chrome warm: still no AdobeID user JWT (cookie-only guest)"); + // Still return ARP if possible — caller may already have JWT + if (!opts.accessToken) return null; + jwt = opts.accessToken; + } + + // Give Forter SDK time to mint a NEW forterToken (stale paste is the usual 408 root cause). + const forterWaitMs = Number(process.env.ADOBE_FIREFLY_FORTER_WAIT_MS || 45_000); + await waitForFresherForter(context, forterBefore, forterWaitMs, log); + + // Second humanize + short settle after token land + page = await ensureLivePage(context, page); + await humanize(page); + await safePageWait(page, 2_000); + + let { arp, cookie } = await buildArpFromContext(context, page); + if (!arp) { + log?.warn?.("ADOBE-FIREFLY", "Chrome warm: could not rebuild ARP from jar — one more reload"); + await gotoFirefly(); + await safePageWait(page, 8_000); + await humanize(page); + await waitForFresherForter(context, forterBefore, 20_000, log); + ({ arp, cookie } = await buildArpFromContext(context, page)); + } + if (!arp) { + log?.warn?.("ADOBE-FIREFLY", "Chrome warm: could not rebuild ARP from jar"); + return null; + } + + // Prove colligo accepts this ARP. Default ON for recovery path; env can force either way. + const shouldPing = + opts.proveWithPing === true || + process.env.ADOBE_FIREFLY_CHROME_PING === "1" || + (opts.allowWithoutEnvOptIn && process.env.ADOBE_FIREFLY_CHROME_PING !== "0"); + if (shouldPing) { + page = await ensureLivePage(context, page); + const ok = await pingGenerateInPage(page, jwt, arp, log); + if (!ok) { + log?.warn?.( + "ADOBE-FIREFLY", + "Chrome ping generate failed — waiting for forter once more and rebuilding ARP" + ); + await waitForFresherForter(context, extractAdobeForterTimestampMs(cookie), 20_000, log); + ({ arp, cookie } = await buildArpFromContext(context, page)); + if (arp) { + page = await ensureLivePage(context, page); + const ok2 = await pingGenerateInPage(page, jwt, arp, log); + if (!ok2) { + log?.warn?.("ADOBE-FIREFLY", "Chrome ping still failed — returning ARP for node retry"); + } + } + } + } + + rt.page = page; + rt.lastWarmAt = Date.now(); + const ftrTs = extractAdobeForterTimestampMs(cookie); + log?.info?.( + "ADOBE-FIREFLY", + `Chrome warm OK (mode=${rt.mode}, arpLen=${arp.length}, forterTs=${ftrTs || 0}, forterDeltaMs=${ftrTs && forterBefore ? ftrTs - forterBefore : "n/a"}, user=${String(decodeAdobeJwtPayload(jwt)?.user_id || "").slice(0, 20)})` + ); + + return { + accessToken: jwt, + cookie, + arpSessionId: arp, + tokenExpiresAt: (() => { + const p = decodeAdobeJwtPayload(jwt); + const created = Number(p?.created_at || 0); + const exp = Number(p?.expires_in || 0); + return created && exp ? created + exp : Date.now() + 20 * 3600_000; + })(), + updatedAt: Date.now(), + fingerprint: "chrome", + source: "browser" as const, + }; + }); + + // Serialize warms + warmChain = run.then( + () => undefined, + () => undefined + ); + try { + return await run; + } catch (err) { + opts.log?.warn?.( + "ADOBE-FIREFLY", + `Chrome warm failed: ${err instanceof Error ? err.message : String(err)}` + ); + // Soft-reset page/browser handle but do not kill Chrome process — reuse next warm. + if (runtime) { + runtime.page = null; + try { + await runtime.browser?.close(); + } catch { + /* ignore */ + } + runtime.browser = null; + runtime.context = null; + } + runtime = null; + return null; + } +} + +/** + * Wipe Adobe SSO from the managed profile so "Add Account" can log into a *new* identity + * instead of silently reusing the previous Adobe session. + */ +async function clearAdobeBrowserSession( + context: import("playwright").BrowserContext, + page: import("playwright").Page, + log?: Log +): Promise { + try { + await context.clearCookies(); + } catch { + /* ignore */ + } + try { + await page.goto("https://firefly.adobe.com/", { + waitUntil: "domcontentloaded", + timeout: 60_000, + }); + await page + .evaluate(() => { + try { + sessionStorage.clear(); + } catch { + /* ignore */ + } + try { + localStorage.clear(); + } catch { + /* ignore */ + } + }) + .catch(() => {}); + } catch { + /* ignore */ + } + // Best-effort IMS logout so the next load shows the sign-in UI. + try { + await page.goto( + "https://auth.services.adobe.com/en_US/index.html?callback=https%3A%2F%2Ffirefly.adobe.com%2F", + { + waitUntil: "domcontentloaded", + timeout: 45_000, + } + ); + await safePageWait(page, 1500); + } catch { + /* ignore */ + } + log?.info?.("ADOBE-FIREFLY", "sign-in: cleared prior Adobe session for a fresh login"); +} + +/** + * Interactive one-time sign-in for the "browser session" credential model. + * Opens a VISIBLE managed Chrome (persistent profile), navigates to Firefly, and waits for the + * user to log in. Returns the IMS JWT + cookie jar so generate works immediately without + * depending on sessionStorage surviving a browser close. + * Never throws — returns { success:false } on timeout / unavailable. + */ +export async function loginAdobeFireflyViaChrome(opts: { + cookie?: string; + /** Max time to wait for the user to complete login (ms). Default 5 min. */ + waitForLoginMs?: number; + /** + * When true (default for "Add Account"), wipe the prior Adobe SSO so a *new* account can be + * signed in instead of reopening the previous logged-in profile. + */ + freshSession?: boolean; + log?: Log; +}): Promise<{ + success: boolean; + account?: string; + accessToken?: string; + cookie?: string; + arpSessionId?: string; +}> { + if (process.env.ADOBE_FIREFLY_BROWSER_REFRESH === "0") { + return { success: false }; + } + const log = opts.log; + const prev = modeOverride; + modeOverride = "visible"; + const fresh = opts.freshSession !== false; // default true for multi-account Add Account + try { + // Fresh visible window (a cached off-screen CDP would be parked off-display for login). + // forceRestart ALWAYS kills port 9334 + restarts with on-screen bounds. + const rt = await ensureChromeStarted(log, { forceRestart: true }); + const context = rt.context!; + let page = await ensureLivePage(context, rt.page); + + // Re-assert on-screen + foreground (profile may re-apply bad bounds after first paint). + await forceChromeWindowOnScreen(rt.browser!, page, log); + + if (fresh) { + await clearAdobeBrowserSession(context, page, log); + page = await ensureLivePage(context, null); + rt.lastCookieSeed = ""; + } else { + const cookieIn = opts.cookie ? extractAdobeCookieHeader(opts.cookie) || opts.cookie : ""; + if (cookieIn) { + const n = await seedCookies(context, cookieIn); + rt.lastCookieSeed = cookieIn; + log?.info?.("ADOBE-FIREFLY", `sign-in: seeded ${n} cookie entries as a hint`); + } + } + + await page + .goto("https://firefly.adobe.com/", { waitUntil: "domcontentloaded", timeout: 90_000 }) + .catch(() => {}); + page = await ensureLivePage(context, page); + await forceChromeWindowOnScreen(rt.browser!, page, log); + log?.info?.( + "ADOBE-FIREFLY", + `sign-in: Chrome window open ON-SCREEN (fresh=${fresh}) — waiting for Adobe login…` + ); + + const waitMs = + opts.waitForLoginMs ?? Number(process.env.ADOBE_FIREFLY_LOGIN_WAIT_MS || 300_000); + const start = Date.now(); + let jwt = ""; + while (Date.now() - start < waitMs) { + await safePageWait(page, 2500); + page = await ensureLivePage(context, page); + jwt = await readSpaUserJwt(page).catch(() => ""); + if (jwt && isAdobeUserAccessToken(jwt)) break; + } + const ok = Boolean(jwt && isAdobeUserAccessToken(jwt)); + const account = ok ? String(decodeAdobeJwtPayload(jwt)?.user_id || "") : undefined; + + // Capture durable credentials BEFORE closing the window (sessionStorage JWT dies with the tab). + let cookie = ""; + let arpSessionId = ""; + if (ok) { + try { + const built = await buildArpFromContext(context, page); + cookie = extractAdobeCookieHeader(built.cookie) || built.cookie || ""; + arpSessionId = built.arp || ""; + } catch { + cookie = (await jarCookieHeader(context).catch(() => "")) || ""; + } + } + + log?.info?.( + "ADOBE-FIREFLY", + ok + ? `sign-in OK (account=${account?.slice(0, 24)}, cookieLen=${cookie.length}, arpLen=${arpSessionId.length})` + : "sign-in timed out — no AdobeID session" + ); + + // Close the visible window; the persistent profile keeps the SSO for later headless warms. + try { + await rt.browser?.close(); + } catch { + /* ignore */ + } + runtime = null; + return { + success: ok, + account, + accessToken: ok ? jwt : undefined, + cookie: ok ? cookie : undefined, + arpSessionId: ok ? arpSessionId : undefined, + }; + } catch (err) { + log?.warn?.( + "ADOBE-FIREFLY", + `sign-in failed: ${err instanceof Error ? err.message : String(err)}` + ); + try { + await runtime?.browser?.close(); + } catch { + /* ignore */ + } + runtime = null; + return { success: false }; + } finally { + modeOverride = prev; + } +} + +async function pingGenerateInPage( + page: import("playwright").Page, + token: string, + arp: string, + log?: Log +): Promise { + try { + const res = await page.evaluate( + async ({ token, arp }) => { + const claims = JSON.parse( + atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")) + ) as { user_id?: string }; + const prompt = "ping"; + const data = new TextEncoder().encode(String(claims.user_id || "") + "-" + prompt); + const hash = await crypto.subtle.digest("SHA-256", data); + const nonce = [...new Uint8Array(hash)] + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + const r = await fetch("https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async", { + method: "POST", + headers: { + Authorization: "Bearer " + token, + "x-api-key": "clio-playground-web", + "content-type": "application/json", + accept: "*/*", + "x-nonce": nonce, + "x-arp-session-id": arp, + }, + credentials: "include", + body: JSON.stringify({ + n: 1, + seeds: [1], + output: { storeInputs: true }, + prompt, + referenceBlobs: [], + modelSpecificPayload: { size: "auto" }, + modelId: "gpt-image", + modelVersion: "2", + generationMetadata: { module: "text2image", submodule: "ff-image-generate" }, + generationSettings: { detailLevel: 1 }, + }), + }); + return { status: r.status, body: (await r.text()).slice(0, 120) }; + }, + { token, arp } + ); + log?.info?.("ADOBE-FIREFLY", `Chrome ping generate status=${res.status}`); + return res.status === 200 || res.status === 202; + } catch (e) { + log?.warn?.( + "ADOBE-FIREFLY", + `Chrome ping error: ${e instanceof Error ? e.message : String(e)}` + ); + return false; + } +} + +/** + * Submit generate-async inside the warmed Chrome page (same TLS/cookie jar as SPA). + * Falls back to null so caller can use node fetch with the warmed ARP. + */ +export async function adobeFireflyGenerateInChrome(opts: { + accessToken: string; + arpSessionId: string; + payload: Record; + prompt: string; + log?: Log; +}): Promise<{ status: number; body: string; headers: Record } | null> { + if (!runtime?.page) return null; + try { + const res = await runtime.page.evaluate( + async ({ token, arp, payload, prompt }) => { + const claims = JSON.parse( + atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")) + ) as { user_id?: string }; + const data = new TextEncoder().encode( + String(claims.user_id || "") + "-" + String(prompt || "").slice(0, 256) + ); + const hash = await crypto.subtle.digest("SHA-256", data); + const nonce = [...new Uint8Array(hash)] + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + const r = await fetch("https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async", { + method: "POST", + headers: { + Authorization: "Bearer " + token, + "x-api-key": "clio-playground-web", + "content-type": "application/json", + accept: "*/*", + "x-nonce": nonce, + "x-arp-session-id": arp, + }, + credentials: "include", + body: JSON.stringify(payload), + }); + const headers: Record = {}; + r.headers.forEach((v, k) => { + headers[k] = v; + }); + return { status: r.status, body: await r.text(), headers }; + }, + { + token: opts.accessToken, + arp: opts.arpSessionId, + payload: opts.payload, + prompt: opts.prompt, + } + ); + return res; + } catch (e) { + opts.log?.warn?.( + "ADOBE-FIREFLY", + `in-Chrome generate failed: ${e instanceof Error ? e.message : String(e)}` + ); + return null; + } +} + +/** Test helper */ +export function __resetAdobeFireflyChromeRuntimeForTests(): void { + runtime = null; + warmChain = Promise.resolve(); +} diff --git a/open-sse/services/adobeFireflyClient.ts b/open-sse/services/adobeFireflyClient.ts index 5bef8c7491..34f1fd2e3a 100644 --- a/open-sse/services/adobeFireflyClient.ts +++ b/open-sse/services/adobeFireflyClient.ts @@ -1751,7 +1751,7 @@ export function formatAdobeSystemUnderLoadError( `Adobe Firefly ${kind} generation failed (HTTP 408 "system under load", after ${attempts} attempt` + `${attempts === 1 ? "" : "s"}). JWT was accepted for balance/discovery but colligo rejected the risk session ` + `(Forter/Arkose stale or rate-limited). The app spaces submits, sticks to the last working x-arp-session-id, ` + - `and on 408 auto-warms a fresh Forter/ARP via off-screen headed Chrome (not headless — colligo rejects that). ` + + `and on 408 auto-warms Forter/ARP via off-screen Chrome CDP (true headless is rejected by colligo — set ADOBE_FIREFLY_CHROME_HEADLESS=1 only for debug). ` + `Paste the full firefly.adobe.com Cookie once with the JWT so recovery can run. If it still fails after that, ` + `open firefly.adobe.com, generate one image in-browser, then paste a FRESH multi-line credential (JWT + Cookie) once.` ); @@ -2322,6 +2322,9 @@ async function pollAdobeJob(opts: { kind: "image" | "video"; timeoutMs: number; pollIntervalMs?: number; + /** Optional session cookie so a mid-poll 401 can renew JWT once via CDP. */ + sessionCookie?: string; + sessionFingerprint?: string; fetchImpl?: typeof fetch; log?: { info?: (...args: unknown[]) => void; @@ -2334,12 +2337,14 @@ async function pollAdobeJob(opts: { opts.pollIntervalMs && opts.pollIntervalMs > 0 ? opts.pollIntervalMs : DEFAULT_POLL_INTERVAL_MS; let attempt = 0; let latest: unknown = {}; + let accessToken = opts.accessToken; + let authRefreshAttempted = false; while (Date.now() < deadline) { attempt += 1; const pollResp = await fetchImpl(opts.pollUrl, { method: "GET", - headers: buildAdobePollHeaders(opts.accessToken), + headers: buildAdobePollHeaders(accessToken), }); if (pollResp.status === 401 || pollResp.status === 403) { @@ -2351,6 +2356,44 @@ async function pollAdobeJob(opts: { "quota_exhausted" ); } + // One CDP JWT renewal mid-poll (long jobs can outlive a near-expiry IMS token). + if (!authRefreshAttempted && opts.sessionCookie) { + authRefreshAttempted = true; + try { + const { + rotateAdobeFireflySessionOnError, + fingerprintAdobeCredential, + estimateAdobeTokenExpiry, + } = await import("./adobeFireflySession.ts"); + const fp = + String(opts.sessionFingerprint || "").trim() || + fingerprintAdobeCredential( + [accessToken, opts.sessionCookie].filter(Boolean).join("\n") + ); + const refreshed = await rotateAdobeFireflySessionOnError( + { + accessToken, + cookie: opts.sessionCookie, + arpSessionId: "", + tokenExpiresAt: estimateAdobeTokenExpiry(accessToken), + updatedAt: Date.now(), + fingerprint: fp, + source: "rebuild", + }, + { attempt: 3, authFailure: true, tryBrowser: true, log: opts.log } + ); + if (refreshed?.accessToken && isAdobeUserAccessToken(refreshed.accessToken)) { + accessToken = refreshed.accessToken; + opts.log?.info?.( + "ADOBE-FIREFLY", + `poll auth ${pollResp.status}; retrying once with renewed JWT` + ); + continue; + } + } catch { + /* fall through to auth error */ + } + } throw new AdobeFireflyError("Adobe Firefly token invalid or expired", 401, "auth"); } @@ -2438,6 +2481,8 @@ export async function adobeFireflyGenerateImage(opts: { arpSessionId?: string; /** Session cache key from ensureAdobeFireflySession — sticky ARP across batch jobs. */ sessionFingerprint?: string; + /** Chrome profile key (provider connection id) for CDP warm/login isolation. */ + sessionBrowserKey?: string; timeoutMs?: number; fetchImpl?: typeof fetch; log?: { @@ -2489,11 +2534,14 @@ export async function adobeFireflyGenerateImage(opts: { const fingerprint = String(opts.sessionFingerprint || "").trim() || fingerprintAdobeCredential([accessToken, activeCookie].filter(Boolean).join("\n")); + const browserSessionKey = String(opts.sessionBrowserKey || "").trim() || fingerprint; - // Gate serialize + min gap: mid-batch 408 is often rate-limit thrash, not "dead" ARP. - await withAdobeFireflySubmitGate(async () => { - for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) { - const submitResp = await fetchImpl(ADOBE_FIREFLY_IMAGE_SUBMIT_URL, { + // Gate ONLY the actual generate-async HTTP call (min gap). CDP warm / backoff run + // outside so interactive browser login and other Firefly submits are not blocked for minutes. + let submitOk = false; + for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) { + const submitResp = await withAdobeFireflySubmitGate(() => + fetchImpl(ADOBE_FIREFLY_IMAGE_SUBMIT_URL, { method: "POST", headers: buildAdobeSubmitHeaders(accessToken, { arpSessionId, @@ -2501,124 +2549,151 @@ export async function adobeFireflyGenerateImage(opts: { cookie: activeCookie || undefined, }), body: JSON.stringify(payload), - }); + }) + ); - if (submitResp.status === 401 || submitResp.status === 403) { - noteAdobeFireflySubmitFailure(); - const accessError = submitResp.headers.get("x-access-error") || ""; - if (accessError === "taste_exhausted") { - throw new AdobeFireflyError( - "Adobe Firefly quota exhausted for this account", - 429, - "quota_exhausted" - ); - } - if (!authRefreshAttempted && attempt < SUBMIT_MAX_ATTEMPTS) { - authRefreshAttempted = true; - const refreshed = await rotateAdobeFireflySessionOnError( - { - accessToken, - cookie: activeCookie, - arpSessionId, - tokenExpiresAt: estimateAdobeTokenExpiry(accessToken), - updatedAt: Date.now(), - fingerprint, - source: "rebuild", - }, - { attempt, authFailure: true, tryBrowser: true, log: opts.log } - ).catch(() => null); - if (refreshed?.accessToken && refreshed?.arpSessionId) { - accessToken = refreshed.accessToken; - activeCookie = refreshed.cookie || activeCookie; - arpSessionId = refreshed.arpSessionId; - opts.log?.info?.( - "ADOBE-FIREFLY", - `image submit auth ${submitResp.status}; retrying once with renewed CDP session` - ); - continue; - } - } + if (submitResp.status === 401 || submitResp.status === 403) { + noteAdobeFireflySubmitFailure(); + const accessError = submitResp.headers.get("x-access-error") || ""; + if (accessError === "taste_exhausted") { throw new AdobeFireflyError( - "Adobe Firefly session is no longer authenticated and automatic browser renewal failed. " + - "Sign in once through the Adobe Firefly browser login to restore durable renewal.", - 401, - "auth" + "Adobe Firefly quota exhausted for this account", + 429, + "quota_exhausted" ); } - - if (!submitResp.ok) { - const text = await submitResp.text().catch(() => ""); - if (isAdobeTransientSubmitError(submitResp.status, text)) { - sawSystemUnderLoad = true; - } - lastSubmitError = `Adobe Firefly image submit failed (${submitResp.status}): ${sanitizeErrorMessage(text.slice(0, 300))}`; - if (isAdobeTransientSubmitError(submitResp.status, text) && attempt < SUBMIT_MAX_ATTEMPTS) { - try { - if (activeCookie) { - const rotated = await rotateAdobeFireflySessionOnError( - { - accessToken, - cookie: activeCookie, - arpSessionId, - tokenExpiresAt: estimateAdobeTokenExpiry(accessToken), - updatedAt: Date.now(), - fingerprint, - source: "rebuild", - }, - { - // Stale forter warms immediately; fresh forter quiet-reuses on 1–2 then warms. - attempt, - tryBrowser: process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "0", - log: opts.log, - } - ); - accessToken = rotated.accessToken || accessToken; - activeCookie = rotated.cookie || activeCookie; - arpSessionId = rotated.arpSessionId; - } else { - arpSessionId = resolveAdobeArpSessionIdSmart(sessionCookie, { - rotate: true, - }); - } - } catch { - // Keep prior ARP — synthetic thrash rarely recovers colligo 408. - } - const base = submitBaseDelayMs(); - const delay = - base <= 50 - ? base - : Math.min(90_000, base * Math.pow(2, attempt - 1)) + - Math.floor(Math.random() * 1500); + if (!authRefreshAttempted && attempt < SUBMIT_MAX_ATTEMPTS) { + authRefreshAttempted = true; + const refreshed = await rotateAdobeFireflySessionOnError( + { + accessToken, + cookie: activeCookie, + arpSessionId, + tokenExpiresAt: estimateAdobeTokenExpiry(accessToken), + updatedAt: Date.now(), + fingerprint, + browserSessionKey, + source: "rebuild", + }, + { attempt, authFailure: true, tryBrowser: true, log: opts.log } + ).catch(() => null); + if (refreshed?.accessToken && refreshed?.arpSessionId) { + accessToken = refreshed.accessToken; + activeCookie = refreshed.cookie || activeCookie; + arpSessionId = refreshed.arpSessionId; opts.log?.info?.( "ADOBE-FIREFLY", - `image submit transient ${submitResp.status}, retry ${attempt}/${SUBMIT_MAX_ATTEMPTS} in ${delay}ms (recovery attempt=${attempt})` + `image submit auth ${submitResp.status}; retrying once with renewed CDP session` ); - await sleep(delay); continue; } - noteAdobeFireflySubmitFailure(); - if (sawSystemUnderLoad && isAdobeTransientSubmitError(submitResp.status, text)) { + } + throw new AdobeFireflyError( + "Adobe Firefly session is no longer authenticated and automatic browser renewal failed. " + + "Sign in once through the Adobe Firefly browser login to restore durable renewal.", + 401, + "auth" + ); + } + + if (!submitResp.ok) { + const text = await submitResp.text().catch(() => ""); + if (isAdobeTransientSubmitError(submitResp.status, text)) { + sawSystemUnderLoad = true; + } + lastSubmitError = `Adobe Firefly image submit failed (${submitResp.status}): ${sanitizeErrorMessage(text.slice(0, 300))}`; + if (isAdobeTransientSubmitError(submitResp.status, text) && attempt < SUBMIT_MAX_ATTEMPTS) { + const { getAdobeForterAgeMs: forterAgeMsFn, extractAdobeForterTimestampMs: forterTsFn } = + await import("./adobeFireflySession.ts"); + // Only treat as known-stale when the cookie embeds a parseable forter timestamp. + // Missing timestamp (tests / synthetic ARP) must keep the full retry ladder. + const forterTs = forterTsFn(activeCookie || ""); + const forterAgeBefore = forterAgeMsFn(activeCookie || ""); + const forterKnownStale = + forterTs > 0 && Number.isFinite(forterAgeBefore) && forterAgeBefore > 4 * 60_000; + // Stale risk session: at most 2 attempts (warm once + one retry). Avoid ~600s thrash. + if (forterKnownStale && attempt >= 2) { + noteAdobeFireflySubmitFailure(); throw new AdobeFireflyError( - formatAdobeSystemUnderLoadError("image", attempt, { - hadBrowserArp, - }), + formatAdobeSystemUnderLoadError("image", attempt, { hadBrowserArp }) + + " Risk session looks expired — open Providers → Adobe Firefly → Sign in with browser once.", 408, "system_under_load" ); } + try { + if (activeCookie) { + const rotated = await rotateAdobeFireflySessionOnError( + { + accessToken, + cookie: activeCookie, + arpSessionId, + tokenExpiresAt: estimateAdobeTokenExpiry(accessToken), + updatedAt: Date.now(), + fingerprint, + browserSessionKey, + source: "rebuild", + }, + { + // Stale forter warms immediately; fresh forter quiet-reuses on 1–2 then warms. + attempt, + tryBrowser: process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "0", + log: opts.log, + } + ); + accessToken = rotated.accessToken || accessToken; + activeCookie = rotated.cookie || activeCookie; + arpSessionId = rotated.arpSessionId; + } else { + arpSessionId = resolveAdobeArpSessionIdSmart(sessionCookie, { + rotate: true, + }); + } + } catch { + // Keep prior ARP — synthetic thrash rarely recovers colligo 408. + } + const base = submitBaseDelayMs(); + const delay = + base <= 50 + ? base + : Math.min(90_000, base * Math.pow(2, attempt - 1)) + Math.floor(Math.random() * 1500); + opts.log?.info?.( + "ADOBE-FIREFLY", + `image submit transient ${submitResp.status}, retry ${attempt}/${SUBMIT_MAX_ATTEMPTS} in ${delay}ms (recovery attempt=${attempt})` + ); + await sleep(delay); + continue; + } + noteAdobeFireflySubmitFailure(); + if (sawSystemUnderLoad && isAdobeTransientSubmitError(submitResp.status, text)) { throw new AdobeFireflyError( - lastSubmitError, - submitResp.status >= 400 && submitResp.status < 500 ? submitResp.status : 502 + formatAdobeSystemUnderLoadError("image", attempt, { + hadBrowserArp, + }), + 408, + "system_under_load" ); } - - submitData = await submitResp.json().catch(() => ({})); - submitHeaders = submitResp.headers; - // Sticky: remember ARP that colligo accepted so the next batch image reuses it. - markAdobeFireflyArpSuccess(fingerprint, arpSessionId); - break; + throw new AdobeFireflyError( + lastSubmitError, + submitResp.status >= 400 && submitResp.status < 500 ? submitResp.status : 502 + ); } - }); + + submitData = await submitResp.json().catch(() => ({})); + submitHeaders = submitResp.headers; + // Sticky: remember ARP that colligo accepted so the next batch image reuses it. + markAdobeFireflyArpSuccess(fingerprint, arpSessionId); + submitOk = true; + break; + } + if (!submitOk && !lastSubmitError) { + throw new AdobeFireflyError( + formatAdobeSystemUnderLoadError("image", SUBMIT_MAX_ATTEMPTS, { hadBrowserArp }), + 408, + "system_under_load" + ); + } let pollUrl = extractAdobeResultLink(submitHeaders, submitData); if (!pollUrl) { @@ -2668,6 +2743,8 @@ export async function adobeFireflyGenerateVideo(opts: { arpSessionId?: string; /** Session cache key from ensureAdobeFireflySession — sticky ARP across batch jobs. */ sessionFingerprint?: string; + /** Chrome profile key (provider connection id) for CDP warm/login isolation. */ + sessionBrowserKey?: string; timeoutMs?: number; fetchImpl?: typeof fetch; log?: { @@ -2734,10 +2811,12 @@ export async function adobeFireflyGenerateVideo(opts: { const fingerprint = String(opts.sessionFingerprint || "").trim() || fingerprintAdobeCredential([accessToken, activeCookie].filter(Boolean).join("\n")); + const browserSessionKey = String(opts.sessionBrowserKey || "").trim() || fingerprint; - await withAdobeFireflySubmitGate(async () => { - for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) { - const submitResp = await fetchImpl(ADOBE_FIREFLY_VIDEO_SUBMIT_URL, { + let videoSubmitOk = false; + for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) { + const submitResp = await withAdobeFireflySubmitGate(() => + fetchImpl(ADOBE_FIREFLY_VIDEO_SUBMIT_URL, { method: "POST", headers: buildAdobeSubmitHeaders(accessToken, { arpSessionId, @@ -2745,122 +2824,146 @@ export async function adobeFireflyGenerateVideo(opts: { cookie: activeCookie || undefined, }), body: JSON.stringify(payload), - }); + }) + ); - if (submitResp.status === 401 || submitResp.status === 403) { - noteAdobeFireflySubmitFailure(); - const accessError = submitResp.headers.get("x-access-error") || ""; - if (accessError === "taste_exhausted") { - throw new AdobeFireflyError( - "Adobe Firefly quota exhausted for this account", - 429, - "quota_exhausted" - ); - } - if (!authRefreshAttempted && attempt < SUBMIT_MAX_ATTEMPTS) { - authRefreshAttempted = true; - const refreshed = await rotateAdobeFireflySessionOnError( - { - accessToken, - cookie: activeCookie, - arpSessionId, - tokenExpiresAt: estimateAdobeTokenExpiry(accessToken), - updatedAt: Date.now(), - fingerprint, - source: "rebuild", - }, - { attempt, authFailure: true, tryBrowser: true, log: opts.log } - ).catch(() => null); - if (refreshed?.accessToken && refreshed?.arpSessionId) { - accessToken = refreshed.accessToken; - activeCookie = refreshed.cookie || activeCookie; - arpSessionId = refreshed.arpSessionId; - opts.log?.info?.( - "ADOBE-FIREFLY", - `video submit auth ${submitResp.status}; retrying once with renewed CDP session` - ); - continue; - } - } + if (submitResp.status === 401 || submitResp.status === 403) { + noteAdobeFireflySubmitFailure(); + const accessError = submitResp.headers.get("x-access-error") || ""; + if (accessError === "taste_exhausted") { throw new AdobeFireflyError( - "Adobe Firefly session is no longer authenticated and automatic browser renewal failed. " + - "Sign in once through the Adobe Firefly browser login to restore durable renewal.", - 401, - "auth" + "Adobe Firefly quota exhausted for this account", + 429, + "quota_exhausted" ); } - - if (!submitResp.ok) { - const text = await submitResp.text().catch(() => ""); - if (isAdobeTransientSubmitError(submitResp.status, text)) { - sawSystemUnderLoad = true; - } - lastSubmitError = `Adobe Firefly video submit failed (${submitResp.status}): ${sanitizeErrorMessage(text.slice(0, 300))}`; - if (isAdobeTransientSubmitError(submitResp.status, text) && attempt < SUBMIT_MAX_ATTEMPTS) { - try { - if (activeCookie) { - const rotated = await rotateAdobeFireflySessionOnError( - { - accessToken, - cookie: activeCookie, - arpSessionId, - tokenExpiresAt: estimateAdobeTokenExpiry(accessToken), - updatedAt: Date.now(), - fingerprint, - source: "rebuild", - }, - { - attempt, - tryBrowser: process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "0", - log: opts.log, - } - ); - accessToken = rotated.accessToken || accessToken; - activeCookie = rotated.cookie || activeCookie; - arpSessionId = rotated.arpSessionId; - } else { - arpSessionId = resolveAdobeArpSessionIdSmart(sessionCookie, { - rotate: true, - }); - } - } catch { - /* keep prior ARP */ - } - const base = submitBaseDelayMs(); - const delay = - base <= 50 - ? base - : Math.min(90_000, base * Math.pow(2, attempt - 1)) + - Math.floor(Math.random() * 1500); + if (!authRefreshAttempted && attempt < SUBMIT_MAX_ATTEMPTS) { + authRefreshAttempted = true; + const refreshed = await rotateAdobeFireflySessionOnError( + { + accessToken, + cookie: activeCookie, + arpSessionId, + tokenExpiresAt: estimateAdobeTokenExpiry(accessToken), + updatedAt: Date.now(), + fingerprint, + browserSessionKey, + source: "rebuild", + }, + { attempt, authFailure: true, tryBrowser: true, log: opts.log } + ).catch(() => null); + if (refreshed?.accessToken && refreshed?.arpSessionId) { + accessToken = refreshed.accessToken; + activeCookie = refreshed.cookie || activeCookie; + arpSessionId = refreshed.arpSessionId; opts.log?.info?.( "ADOBE-FIREFLY", - `video submit transient ${submitResp.status}, retry ${attempt}/${SUBMIT_MAX_ATTEMPTS} in ${delay}ms (recovery attempt=${attempt})` + `video submit auth ${submitResp.status}; retrying once with renewed CDP session` ); - await sleep(delay); continue; } - noteAdobeFireflySubmitFailure(); - if (sawSystemUnderLoad && isAdobeTransientSubmitError(submitResp.status, text)) { + } + throw new AdobeFireflyError( + "Adobe Firefly session is no longer authenticated and automatic browser renewal failed. " + + "Sign in once through the Adobe Firefly browser login to restore durable renewal.", + 401, + "auth" + ); + } + + if (!submitResp.ok) { + const text = await submitResp.text().catch(() => ""); + if (isAdobeTransientSubmitError(submitResp.status, text)) { + sawSystemUnderLoad = true; + } + lastSubmitError = `Adobe Firefly video submit failed (${submitResp.status}): ${sanitizeErrorMessage(text.slice(0, 300))}`; + if (isAdobeTransientSubmitError(submitResp.status, text) && attempt < SUBMIT_MAX_ATTEMPTS) { + const { getAdobeForterAgeMs: forterAgeMsFn, extractAdobeForterTimestampMs: forterTsFn } = + await import("./adobeFireflySession.ts"); + const forterTs = forterTsFn(activeCookie || ""); + const forterAgeBefore = forterAgeMsFn(activeCookie || ""); + const forterKnownStale = + forterTs > 0 && Number.isFinite(forterAgeBefore) && forterAgeBefore > 4 * 60_000; + if (forterKnownStale && attempt >= 2) { + noteAdobeFireflySubmitFailure(); throw new AdobeFireflyError( - formatAdobeSystemUnderLoadError("video", attempt, { - hadBrowserArp, - }), + formatAdobeSystemUnderLoadError("video", attempt, { hadBrowserArp }) + + " Risk session looks expired — open Providers → Adobe Firefly → Sign in with browser once.", 408, "system_under_load" ); } + try { + if (activeCookie) { + const rotated = await rotateAdobeFireflySessionOnError( + { + accessToken, + cookie: activeCookie, + arpSessionId, + tokenExpiresAt: estimateAdobeTokenExpiry(accessToken), + updatedAt: Date.now(), + fingerprint, + browserSessionKey, + source: "rebuild", + }, + { + attempt, + tryBrowser: process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "0", + log: opts.log, + } + ); + accessToken = rotated.accessToken || accessToken; + activeCookie = rotated.cookie || activeCookie; + arpSessionId = rotated.arpSessionId; + } else { + arpSessionId = resolveAdobeArpSessionIdSmart(sessionCookie, { + rotate: true, + }); + } + } catch { + /* keep prior ARP */ + } + const base = submitBaseDelayMs(); + const delay = + base <= 50 + ? base + : Math.min(90_000, base * Math.pow(2, attempt - 1)) + Math.floor(Math.random() * 1500); + opts.log?.info?.( + "ADOBE-FIREFLY", + `video submit transient ${submitResp.status}, retry ${attempt}/${SUBMIT_MAX_ATTEMPTS} in ${delay}ms (recovery attempt=${attempt})` + ); + await sleep(delay); + continue; + } + noteAdobeFireflySubmitFailure(); + if (sawSystemUnderLoad && isAdobeTransientSubmitError(submitResp.status, text)) { throw new AdobeFireflyError( - lastSubmitError, - submitResp.status >= 400 && submitResp.status < 500 ? submitResp.status : 502 + formatAdobeSystemUnderLoadError("video", attempt, { + hadBrowserArp, + }), + 408, + "system_under_load" ); } - - submitData = await submitResp.json().catch(() => ({})); - submitHeaders = submitResp.headers; - markAdobeFireflyArpSuccess(fingerprint, arpSessionId); - break; + throw new AdobeFireflyError( + lastSubmitError, + submitResp.status >= 400 && submitResp.status < 500 ? submitResp.status : 502 + ); } - }); + + submitData = await submitResp.json().catch(() => ({})); + submitHeaders = submitResp.headers; + markAdobeFireflyArpSuccess(fingerprint, arpSessionId); + videoSubmitOk = true; + break; + } + if (!videoSubmitOk && !lastSubmitError) { + throw new AdobeFireflyError( + formatAdobeSystemUnderLoadError("video", SUBMIT_MAX_ATTEMPTS, { hadBrowserArp }), + 408, + "system_under_load" + ); + } let pollUrl = extractAdobeResultLink(submitHeaders, submitData); if (!pollUrl) { @@ -2885,6 +2988,8 @@ export async function adobeFireflyGenerateVideo(opts: { accessToken, kind: "video", timeoutMs: opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_VIDEO_TIMEOUT_MS, + sessionCookie: activeCookie || sessionCookie || undefined, + sessionFingerprint: fingerprint, fetchImpl, log: opts.log, }); diff --git a/open-sse/services/adobeFireflySession.ts b/open-sse/services/adobeFireflySession.ts index 357592c2d0..d8ab034993 100644 --- a/open-sse/services/adobeFireflySession.ts +++ b/open-sse/services/adobeFireflySession.ts @@ -72,6 +72,9 @@ const sessionCache = new Map(); const browserRefreshInFlight = new Map>(); /** Last ARP that produced HTTP 2xx on generate-async — prefer until colligo 408. */ const lastWorkingArpByFingerprint = new Map(); +/** After a failed force-warm, skip re-launching Chrome for this fingerprint for a short window. */ +const browserWarmFailureCooldown = new Map(); +const BROWSER_WARM_FAIL_COOLDOWN_MS = 90_000; /** Serialize Firefly generate submits + enforce a quiet period (colligo rate-limits look like 408). */ let adobeSubmitChain: Promise = Promise.resolve(); let lastAdobeSubmitAt = 0; @@ -478,6 +481,46 @@ function collectCredentialBlobs( * Uses the same persistent pure-CDP profile as interactive sign-in, including in pkg builds. * Never throws — returns null when unavailable. */ +/** + * Best-effort write refreshed JWT+Cookie back to provider_connections so restarts + * and WinUI sync do not keep serving a guest/stale paste after a successful warm. + */ +async function writeBackAdobeFireflyCredentials( + session: AdobeFireflySession, + log?: AdobeFireflySessionResolveOpts["log"] +): Promise { + const connectionId = String(session.browserSessionKey || "").trim(); + if (!connectionId || connectionId === "legacy-default") return; + if (!isAdobeUserAccessToken(session.accessToken)) return; + // Skip when connectionId looks like a credential fingerprint (32 hex) without a real UUID. + // Real OmniRoute connection ids are UUIDs; still attempt write-back for any non-empty key. + try { + const { updateProviderConnection } = await import("@/lib/db/providers"); + const credential = serializeAdobeFireflyCredential(session); + await updateProviderConnection(connectionId, { + apiKey: credential, + providerSpecificData: { + mode: "browser-profile", + adobeFireflyMode: "browser-profile", + cookie: session.cookie || credential, + access_token: session.accessToken, + browserSessionKey: connectionId, + arpSessionId: session.arpSessionId || "", + refreshedAt: Date.now(), + }, + }); + log?.info?.( + "ADOBE-FIREFLY", + `wrote refreshed JWT+Cookie to connection ${connectionId.slice(0, 8)}…` + ); + } catch (err) { + log?.warn?.( + "ADOBE-FIREFLY", + `credential write-back skipped: ${err instanceof Error ? err.message : String(err)}` + ); + } +} + export async function refreshAdobeSessionViaBrowser( session: AdobeFireflySession, log?: AdobeFireflySessionResolveOpts["log"], @@ -487,48 +530,92 @@ export async function refreshAdobeSessionViaBrowser( // Browser warm is the default engine now — only the explicit kill switch disables it. if (!adobeFireflyBrowserEnabled()) return null; + const coolKey = String(session.browserSessionKey || session.fingerprint || "").trim(); + const coolUntil = coolKey ? browserWarmFailureCooldown.get(coolKey) || 0 : 0; + if (force && coolUntil > Date.now()) { + log?.warn?.( + "ADOBE-FIREFLY", + `skip CDP warm (cooldown ${Math.ceil((coolUntil - Date.now()) / 1000)}s after recent failure)` + ); + return null; + } + try { + const baseFtr = extractAdobeForterTimestampMs(session.cookie || ""); const { refreshAdobeFireflyViaCdp } = await import("./adobeFireflyBrowserLogin.ts"); const warmed = await refreshAdobeFireflyViaCdp({ cookie: session.cookie, accessToken: session.accessToken, log, timeoutMs: force ? 90_000 : 75_000, - sessionKey: session.browserSessionKey, + sessionKey: session.browserSessionKey || session.fingerprint, }); - if (!warmed) return null; + if (!warmed) { + if (force && coolKey) { + browserWarmFailureCooldown.set(coolKey, Date.now() + BROWSER_WARM_FAIL_COOLDOWN_MS); + } + return null; + } + if (coolKey) browserWarmFailureCooldown.delete(coolKey); + + // Prefer warm cookie as authority for risk pieces (do not re-merge stale forter over new). + // On force warm, prefer the warmed cookie as authority (do not re-merge hours-old forter + // from the previous session blob over a freshly minted jar). + const nextCookie = force + ? warmed.cookie || session.cookie + : warmed.cookie + ? mergeAdobeCookieHeaders(session.cookie || "", warmed.cookie) + : session.cookie; + const warmFtr = extractAdobeForterTimestampMs(nextCookie); + const warmAge = warmFtr > 0 ? Math.max(0, Date.now() - warmFtr) : Number.POSITIVE_INFINITY; + // Force path: require a parseable forter younger than FORTER_STALE (or strictly newer than base). + if (force) { + const advanced = + warmFtr > 0 && (baseFtr <= 0 || warmFtr > baseFtr || warmAge < FORTER_STALE_MS); + if (!advanced) { + log?.warn?.( + "ADOBE-FIREFLY", + `CDP warm rejected: forter not advanced (base=${baseFtr}, warm=${warmFtr || 0}, ageMs=${Number.isFinite(warmAge) ? warmAge : "inf"})` + ); + return null; + } + } - const nextCookie = mergeAdobeCookieHeaders(session.cookie, warmed.cookie || ""); const nextArp = warmed.arpSessionId || buildAdobeArpSessionIdFromCookies(nextCookie) || extractAdobeArpSessionId(nextCookie); if (!nextArp) return null; + const nextToken = + (warmed.accessToken && isAdobeUserAccessToken(warmed.accessToken) + ? warmed.accessToken + : "") || session.accessToken; + if (!isAdobeUserAccessToken(nextToken)) return null; + const next: AdobeFireflySession = { ...session, - accessToken: warmed.accessToken || session.accessToken, + accessToken: nextToken, cookie: nextCookie, arpSessionId: nextArp, - tokenExpiresAt: estimateAdobeTokenExpiry(warmed.accessToken || session.accessToken), + tokenExpiresAt: estimateAdobeTokenExpiry(nextToken), updatedAt: Date.now(), + browserSessionKey: session.browserSessionKey || session.fingerprint, source: "browser", }; - // When warm returns a fresher forter, prefer its cookie entirely for ARP rebuild pieces. - const warmFtr = extractAdobeForterTimestampMs(nextCookie); - const baseFtr = extractAdobeForterTimestampMs(session.cookie || ""); - if (warmFtr > baseFtr && warmed.cookie) { - next.cookie = mergeAdobeCookieHeaders(session.cookie, warmed.cookie); - } sessionCache.set(session.fingerprint, next); saveDiskSession(next); clearAdobeFireflyWorkingArp(session.fingerprint); + void writeBackAdobeFireflyCredentials(next, log); log?.info?.( "ADOBE-FIREFLY", - `durable CDP warm refreshed session (arpLen=${next.arpSessionId.length}, force=${force}, forterTs=${warmFtr || 0})` + `durable CDP warm refreshed session (arpLen=${next.arpSessionId.length}, force=${force}, forterTs=${warmFtr || 0}, forterDeltaMs=${warmFtr && baseFtr ? warmFtr - baseFtr : 0})` ); return next; } catch (err) { + if (force && coolKey) { + browserWarmFailureCooldown.set(coolKey, Date.now() + BROWSER_WARM_FAIL_COOLDOWN_MS); + } log?.warn?.( "ADOBE-FIREFLY", `browser CDP session refresh failed: ${err instanceof Error ? err.message : String(err)}` @@ -555,19 +642,36 @@ export async function ensureAdobeFireflySession( } const joined = blobs.join("\n"); - const fingerprint = fingerprintAdobeCredential(joined); - const browserSessionKey = - String( - opts.credentials?.connectionId || - opts.credentials?.providerSpecificData?.browserSessionKey || - fingerprint - ).trim() || fingerprint; + // Prefer stable connection-scoped fingerprint so JWT/cookie refresh does not orphan + // the session cache / sticky ARP map (paste hash changes every warm write-back). + const connectionId = String( + opts.credentials?.connectionId || + opts.credentials?.providerSpecificData?.browserSessionKey || + "" + ).trim(); + const fingerprint = connectionId + ? fingerprintAdobeCredential(`conn:${connectionId}`) + : fingerprintAdobeCredential(joined); + const browserSessionKey = connectionId || fingerprint; // forceRefresh / rotate always drop in-memory cache for this fingerprint if (opts.forceRefresh) sessionCache.delete(fingerprint); - const cached = sessionCache.get(fingerprint) || loadDiskSession(fingerprint); - if (cached && !opts.forceRefresh) sessionCache.set(fingerprint, cached); + // Also try legacy paste-hash session files (pre-connection-scoped fingerprints). + const legacyFingerprint = fingerprintAdobeCredential(joined); + const cached = + sessionCache.get(fingerprint) || + loadDiskSession(fingerprint) || + (legacyFingerprint !== fingerprint ? loadDiskSession(legacyFingerprint) : null); + if (cached && !opts.forceRefresh) { + // Re-key legacy disk session under the stable connection fingerprint. + const normalized = { + ...cached, + fingerprint, + browserSessionKey: cached.browserSessionKey || browserSessionKey, + }; + sessionCache.set(fingerprint, normalized); + } const fetchImpl = opts.fetchImpl || fetch; let accessToken = ""; @@ -676,12 +780,14 @@ export async function ensureAdobeFireflySession( accessToken, cookie: cookieForSession, arpSessionId: String(arpSessionId || ""), - tokenExpiresAt: estimateAdobeTokenExpiry(accessToken), + tokenExpiresAt: estimateAdobeTokenExpiry(accessToken || cached?.accessToken || ""), updatedAt: Date.now(), fingerprint, browserSessionKey, source: workingFresh ? "cache" : cached?.source || "paste", }; + // Prefer connection-scoped browser profile always (never empty → legacy-default). + if (!session.browserSessionKey) session.browserSessionKey = browserSessionKey; // Off-screen Chrome Forter-warm is now the DEFAULT engine (kill switch: // ADOBE_FIREFLY_BROWSER_REFRESH=0). Warm proactively when we lack a usable session so the @@ -755,8 +861,39 @@ export async function ensureAdobeFireflySession( ); } + // Dead Forter risk session: colligo returns 408 for ~minutes/hours of retries. Fail closed + // with a re-login instruction instead of burning ~600s of generate-async attempts. + // Only when forter timestamp is parseable and old — missing timestamp is not treated as stale + // (JWT-only / synthetic ARP / unit fixtures). + const finalForterTs = extractAdobeForterTimestampMs(session.cookie); + const finalForterAge = getAdobeForterAgeMs(session.cookie); + const hasStickyWorking = + Boolean(workingFresh) && + Date.now() - (lastWorkingArpByFingerprint.get(fingerprint)?.at || 0) < WORKING_ARP_STICKY_MS; + if ( + finalForterTs > 0 && + Number.isFinite(finalForterAge) && + finalForterAge > FORTER_STALE_MS && + !hasStickyWorking && + opts.allowBrowserRefresh !== false + ) { + throw new AdobeFireflyError( + "Adobe Firefly risk session expired (Forter/Arkose). Open Providers → Adobe Firefly → " + + "Add Account (OAuth) → Sign in with browser once. After sign-in the app stores a fresh " + + "JWT+Cookie and refreshes them automatically for later generates.", + 401, + "risk_session_stale" + ); + } + + session.fingerprint = fingerprint; + session.browserSessionKey = session.browserSessionKey || browserSessionKey; sessionCache.set(fingerprint, session); saveDiskSession(session); + // Keep SQLite in sync when we have a real connection + user JWT (best-effort). + if (session.source === "browser" || session.source === "rebuild") { + void writeBackAdobeFireflyCredentials(session, opts.log); + } return session; } @@ -810,8 +947,9 @@ export async function rotateAdobeFireflySessionOnError( return same; } - // Known-stale forter or attempt 3+: cookie rebuild is a no-op. Off-screen headed Chrome mints a - // fresh Forter/ARP (headless browsers are rejected by colligo, so CDP uses off-screen headed mode). + // Known-stale forter or attempt 3+: cookie rebuild is a no-op. CDP warm mints a fresh + // Forter/ARP via offscreen headed Chrome by default (colligo rejects true headless). + // ADOBE_FIREFLY_CHROME_HEADLESS=1 is debug-only and usually keeps returning 408. clearAdobeFireflyWorkingArp(session.fingerprint); noteAdobeFireflySubmitFailure(); @@ -857,6 +995,7 @@ export function __resetAdobeFireflySessionCacheForTests(): void { sessionCache.clear(); browserRefreshInFlight.clear(); lastWorkingArpByFingerprint.clear(); + browserWarmFailureCooldown.clear(); lastAdobeSubmitAt = 0; consecutiveAdobeSubmitSuccesses = 0; adobeSubmitChain = Promise.resolve(); diff --git a/src/app/api/providers/[id]/login/route.ts b/src/app/api/providers/[id]/login/route.ts index 7d843b5eb4..555d503978 100644 --- a/src/app/api/providers/[id]/login/route.ts +++ b/src/app/api/providers/[id]/login/route.ts @@ -20,7 +20,142 @@ function resolveProviderSlug(connection: Record | null): string return ""; } -// тФАтФАтФА POST: Start login flow тФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФАтФА +function isAdobeFireflyProvider( + connection: { provider?: unknown } | null, + providerSlug: string +): boolean { + const raw = String(connection?.provider || "").trim(); + return ADOBE_FIREFLY_SLUGS.has(raw) || ADOBE_FIREFLY_SLUGS.has(providerSlug); +} + +/** + * Persist JWT + Cookie the way desktop clients (and generate) expect: + * multi-line api_key, plus camelCase providerSpecificData for updateProviderConnection. + */ +async function persistAdobeFireflyCredentials( + connectionId: string, + opts: { + accessToken?: string; + cookie?: string; + account?: string; + arpSessionId?: string; + } +): Promise<{ + accessToken: string; + cookie: string; + credential: string; + account: string; +}> { + const accessToken = String(opts.accessToken || "").trim(); + const cookie = String(opts.cookie || "").trim(); + const account = String(opts.account || "").trim(); + const credential = + accessToken && cookie + ? `${accessToken}\n${cookie}` + : accessToken || + cookie || + JSON.stringify({ + mode: "browser-profile", + account, + signedInAt: Date.now(), + }); + + const marker = { + mode: "browser-profile", + account, + signedInAt: Date.now(), + arpSessionId: String(opts.arpSessionId || ""), + }; + + try { + // camelCase only — updateProviderConnection / encryptConnectionFields read apiKey + + // providerSpecificData (snake_case keys are silently ignored and never persisted). + await updateProviderConnection(connectionId, { + apiKey: credential, + providerSpecificData: { + ...marker, + cookie: cookie || credential, + access_token: accessToken || undefined, + }, + }); + } catch { + /* non-fatal — return credentials to the host app either way */ + } + + return { accessToken, cookie, credential, account }; +} + +function adobeFireflySuccessResponse(data: { + accessToken: string; + cookie: string; + credential: string; + account: string; + arpSessionId?: string; + via: "pure-cdp"; +}): NextResponse { + return NextResponse.json({ + success: true, + account: data.account || undefined, + accessToken: data.accessToken || undefined, + cookie: data.cookie || undefined, + arpSessionId: data.arpSessionId || undefined, + credential: data.credential, + credentials: { + access_token: data.accessToken || undefined, + cookie: data.cookie || undefined, + }, + via: data.via, + persisted: true, + }); +} + +/** + * Adobe Firefly browser sign-in: + * pure system Chrome/Edge CDP only (packaged-safe, no Playwright/browser bundle). + */ +async function loginAdobeFirefly( + connectionId: string, + body: { timeout?: unknown; freshSession?: unknown } +): Promise { + const timeout = typeof body.timeout === "number" ? body.timeout : undefined; + const freshSession = typeof body.freshSession === "boolean" ? body.freshSession : true; + + // Pure system-browser CDP is the packaged-safe implementation. Do not open a second browser + // after failure: it creates ambiguous success/error races and the packaged runtime has no + // reliable Playwright browser bundle. + // startAdobeFireflyBrowserLogin always kills its Chrome tree in `finally` (no orphans). + try { + const { startAdobeFireflyBrowserLogin } = + await import("@omniroute/open-sse/services/adobeFireflyBrowserLogin.ts"); + const pure = await startAdobeFireflyBrowserLogin(timeout, { + sessionKey: connectionId, + freshSession, + }); + if (pure.success && pure.credentials?.accessToken) { + const persisted = await persistAdobeFireflyCredentials(connectionId, { + accessToken: pure.credentials.accessToken, + cookie: pure.credentials.cookie, + account: pure.account, + }); + return adobeFireflySuccessResponse({ + ...persisted, + via: "pure-cdp", + }); + } + return NextResponse.json( + { + success: false, + error: pure.error || "Adobe Firefly sign-in did not capture an authenticated IMS JWT.", + }, + { status: 400 } + ); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err); + return NextResponse.json({ success: false, error: msg }, { status: 400 }); + } +} + +// --- POST: Start login flow ------------------------------------------------- export async function POST( req: NextRequest, @@ -39,67 +174,18 @@ export async function POST( timeout?: unknown; freshSession?: unknown; }; - const timeout = typeof body.timeout === "number" ? body.timeout : undefined; const providerSlug = resolveProviderSlug(provider as Record); - // Firefly JWTs exist only on firefly-3p Authorization headers. Use one packaged-safe CDP - // flow, isolate state by connection, and never fall through to a second browser. - if (ADOBE_FIREFLY_SLUGS.has(providerSlug)) { + // Adobe Firefly: dedicated JWT capture (never cookies/localStorage alone). + if (isAdobeFireflyProvider(provider as { provider?: unknown }, providerSlug)) { try { - const { startAdobeFireflyBrowserLogin } = - await import("@omniroute/open-sse/services/adobeFireflyBrowserLogin.ts"); - const result = await startAdobeFireflyBrowserLogin(timeout, { - sessionKey: id, - freshSession: typeof body.freshSession === "boolean" ? body.freshSession : true, - }); - const accessToken = String(result.credentials?.accessToken || "").trim(); - const cookie = String(result.credentials?.cookie || "").trim(); - if (result.success && accessToken) { - const credential = - accessToken && cookie ? `${accessToken}\n${cookie}` : accessToken || cookie; - const marker = { - mode: "browser-profile", - account: result.account || "", - signedInAt: Date.now(), - arpSessionId: result.arpSessionId || "", - }; - try { - await updateProviderConnection(id, { - apiKey: credential, - providerSpecificData: { - ...marker, - cookie: cookie || credential, - access_token: accessToken || undefined, - }, - }); - } catch { - /* non-fatal — return credentials to the host app either way */ - } - return NextResponse.json({ - success: true, - account: result.account, - accessToken: accessToken || undefined, - cookie: cookie || undefined, - arpSessionId: result.arpSessionId || undefined, - credential, - credentials: { - access_token: accessToken || undefined, - cookie: cookie || undefined, - }, - via: "pure-cdp", - persisted: true, - }); - } - return NextResponse.json( - { - success: false, - error: result.error || "Adobe Firefly sign-in did not capture an authenticated IMS JWT.", - }, - { status: 400 } - ); + return await loginAdobeFirefly(id, body); } catch (err) { const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err); - return NextResponse.json({ success: false, error: msg }, { status: 400 }); + return NextResponse.json( + { success: false, error: `Adobe Firefly sign-in error: ${msg}` }, + { status: 500 } + ); } } @@ -110,7 +196,9 @@ export async function POST( // missed and returned "No extraction config" without launching a browser. const { inAppLoginService } = await import("@omniroute/open-sse/services/inAppLoginService.ts"); - const result = await inAppLoginService.startLogin(providerSlug || id, { timeout }); + const result = await inAppLoginService.startLogin(providerSlug || id, { + timeout: typeof body.timeout === "number" ? body.timeout : undefined, + }); // Persist credentials if extraction succeeded if (result.success && result.credentials) { diff --git a/tests/unit/adobe-firefly-browser-login.test.ts b/tests/unit/adobe-firefly-browser-login.test.ts index c925ca71bc..d22cfb4e89 100644 --- a/tests/unit/adobe-firefly-browser-login.test.ts +++ b/tests/unit/adobe-firefly-browser-login.test.ts @@ -5,13 +5,18 @@ import test from "node:test"; import assert from "node:assert/strict"; import { + adobeFireflyBackgroundUsesHeadlessChrome, adobeFireflyBrowserSessionKey, accountLabelFromAdobeJwt, buildAdobeFireflyBrowserArgs, buildAdobeFireflyCookieHeader, clampAdobeFireflyLoginTimeout, extractAdobeBearerTokenFromAuthorization, + extractAdobeForterTimestampFromValue, + extractUserJwtFromStorageRaw, filterAdobeBrowserCookies, + filterSeedCookiesForWarm, + isAdobeRiskCookieName, resolveAdobeAccountLabel, resolveSystemBrowserExecutable, } from "../../open-sse/services/adobeFireflyBrowserLogin.ts"; @@ -89,7 +94,7 @@ test("resolveAdobeAccountLabel uses IMS display name and generic fallback", asyn assert.equal(fallback, "Adobe account"); }); -test("browser args isolate profiles and make fresh interactive login incognito", () => { +test("browser args: interactive headed; background offscreen (Forter-safe), headless opt-in only", () => { const firstKey = adobeFireflyBrowserSessionKey("connection-a"); const secondKey = adobeFireflyBrowserSessionKey("connection-b"); assert.equal(firstKey, adobeFireflyBrowserSessionKey("connection-a")); @@ -101,17 +106,86 @@ test("browser args isolate profiles and make fresh interactive login incognito", interactive: true, freshSession: true, }); - assert.ok(interactive.includes("--incognito")); + // User-initiated Sign in with browser: real window, never headless. + assert.equal(interactive.includes("--headless=new"), false); + assert.ok(interactive.includes("--new-window")); assert.ok(interactive.includes(`--user-data-dir=C:\\profiles\\${firstKey}`)); assert.equal(interactive.at(-1), "https://firefly.adobe.com/"); - const background = buildAdobeFireflyBrowserArgs({ - port: 9223, - userDataDir: `C:\\profiles\\${secondKey}`, - interactive: false, - }); - assert.equal(background.includes("--incognito"), false); - assert.equal(background.at(-1), "about:blank"); + const prevHeadless = process.env.ADOBE_FIREFLY_CHROME_HEADLESS; + delete process.env.ADOBE_FIREFLY_CHROME_HEADLESS; + try { + // Default background: offscreen headed (colligo accepts; true headless → 408). + assert.equal(adobeFireflyBackgroundUsesHeadlessChrome(), false); + const background = buildAdobeFireflyBrowserArgs({ + port: 9223, + userDataDir: `C:\\profiles\\${secondKey}`, + interactive: false, + }); + assert.equal(background.includes("--headless=new"), false); + assert.equal(background.includes("--new-window"), false); + assert.ok(background.includes("--window-position=-32000,-32000")); + assert.ok(background.includes("--start-minimized")); + assert.equal(background.at(-1), "https://firefly.adobe.com/"); + + process.env.ADOBE_FIREFLY_CHROME_HEADLESS = "1"; + assert.equal(adobeFireflyBackgroundUsesHeadlessChrome(), true); + const headless = buildAdobeFireflyBrowserArgs({ + port: 9224, + userDataDir: `C:\\profiles\\${secondKey}`, + interactive: false, + }); + assert.ok(headless.includes("--headless=new")); + } finally { + if (prevHeadless === undefined) delete process.env.ADOBE_FIREFLY_CHROME_HEADLESS; + else process.env.ADOBE_FIREFLY_CHROME_HEADLESS = prevHeadless; + } +}); + +test("isAdobeRiskCookieName flags forter/arkose/sherlock", () => { + assert.equal(isAdobeRiskCookieName("forterToken"), true); + assert.equal(isAdobeRiskCookieName("arkose"), true); + assert.equal(isAdobeRiskCookieName("sherlockToken"), true); + assert.equal(isAdobeRiskCookieName("ff_session_guid"), false); + assert.equal(isAdobeRiskCookieName("aux_sid"), false); +}); + +test("filterSeedCookiesForWarm drops risk cookies on force warm", () => { + const filtered = filterSeedCookiesForWarm( + [ + { name: "forterToken", value: "stale" }, + { name: "arkose", value: "a" }, + { name: "ff_session_guid", value: "sid" }, + { name: "aux_sid", value: "aux" }, + ], + { dropRiskCookies: true } + ); + assert.deepEqual(filtered.map((c) => c.name).sort(), ["aux_sid", "ff_session_guid"]); +}); + +test("extractAdobeForterTimestampFromValue reads embedded ms", () => { + const ftr = "abc_1785777856265__UDF43-mnts-ants-x"; + assert.equal(extractAdobeForterTimestampFromValue(ftr), 1785777856265); + assert.equal(extractAdobeForterTimestampFromValue(""), 0); +}); + +test("extractUserJwtFromStorageRaw prefers user AdobeID JWT", () => { + const guestPayload = Buffer.from( + JSON.stringify({ type: "guest", account_type: "guest", user_id: "x@GuestID" }) + ).toString("base64url"); + const userPayload = Buffer.from( + JSON.stringify({ + type: "access_token", + user_id: "0EB6@AdobeID", + client_id: "clio-playground-web", + created_at: Date.now(), + expires_in: 86400000, + }) + ).toString("base64url"); + const guest = `eyJhbGciOiJIUzI1NiJ9.${guestPayload}.sig`; + const user = `eyJhbGciOiJSUzI1NiJ9.${userPayload}.usersig`; + const raw = JSON.stringify({ tokenValue: guest }) + "\n" + JSON.stringify({ access_token: user }); + assert.equal(extractUserJwtFromStorageRaw(raw), user); }); test("filterAdobeBrowserCookies keeps Adobe SSO domains only", () => {