diff --git a/open-sse/services/adobeFireflyBrowserLogin.ts b/open-sse/services/adobeFireflyBrowserLogin.ts index f40375a2bb..37228d28de 100644 --- a/open-sse/services/adobeFireflyBrowserLogin.ts +++ b/open-sse/services/adobeFireflyBrowserLogin.ts @@ -6,16 +6,15 @@ * cookies/localStorage — the SPA only holds it in memory and attaches it * as `Authorization: Bearer ` on XHRs to firefly-3p.ff.adobe.io. * - * IMPORTANT: The VibeProxyServices.exe is a pkg-packaged Node binary. + * IMPORTANT: The standalone executable is a pkg-packaged Node binary. * Dynamic `import("playwright")` fails there (native bindings / browsers * are not in the package). This module launches the **system** Chrome or * Edge with `--remote-debugging-port` and talks pure Chrome DevTools * Protocol over WebSocket — zero Playwright dependency. */ import { spawn, type ChildProcess } from "node:child_process"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync } from "node:fs"; import { createServer } from "node:net"; -import { tmpdir } from "node:os"; import { join } from "node:path"; import { sanitizeErrorMessage } from "../utils/error.ts"; @@ -34,11 +33,40 @@ const CDP_READY_TIMEOUT_MS = 30_000; export interface AdobeFireflyBrowserLoginResult { success: boolean; credentials?: { accessToken?: string; cookie?: string }; + arpSessionId?: string; /** Best-effort Adobe account label (email or user id) decoded from the JWT. */ account?: string; error?: string; } +export interface AdobeFireflyCdpRefreshResult { + accessToken: string; + cookie: string; + arpSessionId: string; +} + +type AdobeFireflyBrowserLog = { + info?: (...args: unknown[]) => void; + warn?: (...args: unknown[]) => void; +}; + +let cdpBrowserChain: Promise = Promise.resolve(); + +/** + * A non-default profile is required by Chrome 136+ for remote debugging. Keep this profile + * durable: Adobe IMS SSO lives here and is what lets a background warm mint the next user JWT. + */ +export function resolveAdobeFireflyBrowserProfileDir(): string { + const dataRoot = + String(process.env.DATA_DIR || process.env.OMNIROUTE_DATA_DIR || "").trim() || + (process.env.LOCALAPPDATA + ? join(process.env.LOCALAPPDATA, "OmniRoute") + : join(process.cwd(), ".data")); + const profile = join(dataRoot, "adobe-chrome-profile"); + mkdirSync(profile, { recursive: true }); + return profile; +} + export function clampAdobeFireflyLoginTimeout(value: unknown): number { if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_LOGIN_TIMEOUT_MS; return Math.max(MIN_LOGIN_TIMEOUT_MS, Math.min(MAX_LOGIN_TIMEOUT_MS, Math.trunc(value))); @@ -54,7 +82,15 @@ export function extractAdobeBearerTokenFromAuthorization(authHeader: string): st export function buildAdobeFireflyCookieHeader( cookies: Array<{ name: string; value: string; domain?: string }> ): string { - const wanted = ["sherlockToken", "forterToken", "aux_sid", "ff_session_guid"]; + const wanted = [ + "sherlockToken", + "forterToken", + "arkose", + "ff_session_guid", + "aux_sid", + "bfp", + "fpjs", + ]; const parts: string[] = []; for (const wantedName of wanted) { const c = cookies.find( @@ -161,6 +197,23 @@ async function waitForCdpReady( type CdpCookie = { name: string; value: string; domain?: string }; +function parseCookieHeader(cookieHeader: string): Array<{ name: string; value: string }> { + const cookies: Array<{ name: string; value: string }> = []; + for (const part of String(cookieHeader || "").split(";")) { + const idx = part.indexOf("="); + if (idx <= 0) continue; + const name = part.slice(0, idx).trim(); + const value = part.slice(idx + 1).trim(); + if (!name || !value || /[\r\n\0]/.test(name + value)) continue; + cookies.push({ name, value }); + } + return cookies; +} + +function cookieValue(cookies: CdpCookie[], name: string): string { + return cookies.find((cookie) => cookie.name.toLowerCase() === name.toLowerCase())?.value || ""; +} + class CdpSocket { private ws: WebSocket; private nextId = 1; @@ -249,14 +302,27 @@ async function captureViaCdp(opts: { port: number; browserWsUrl: string; timeoutMs: number; -}): Promise<{ accessToken: string; cookies: CdpCookie[] }> { + fallbackAccessToken?: string; + seedCookie?: string; + waitForRiskRefresh?: boolean; +}): Promise<{ + accessToken: string; + cookies: CdpCookie[]; + arpSessionId: string; +}> { let capturedAccessToken = ""; + let capturedArpSessionId = ""; + let latestCookies: CdpCookie[] = []; const pageSockets = new Map(); let browserCdp: CdpSocket | null = null; + const initialForter = + parseCookieHeader(opts.seedCookie || "").find( + (cookie) => cookie.name.toLowerCase() === "fortertoken" + )?.value || ""; + const startedAt = Date.now(); const onEvent = (method: string, params: Record) => { if (method === "Network.requestWillBeSent") { - if (capturedAccessToken) return; const request = params.request as { url?: string; headers?: Record } | undefined; if (!request?.url || !request.url.includes(FIREFLY_3P_HOST_SUFFIX)) return; @@ -264,6 +330,12 @@ async function captureViaCdp(opts: { const auth = headers.Authorization || headers.authorization || headers.AUTHORIZATION || ""; const token = extractAdobeBearerTokenFromAuthorization(auth); if (token) capturedAccessToken = token; + const arp = + headers["x-arp-session-id"] || + headers["X-Arp-Session-Id"] || + headers["X-ARP-SESSION-ID"] || + ""; + if (typeof arp === "string" && arp.trim()) capturedArpSessionId = arp.trim(); } else if (method === "Target.attachedToTarget") { const sessionId = String(params.sessionId || ""); const targetInfo = params.targetInfo as { type?: string; targetId?: string } | undefined; @@ -276,6 +348,20 @@ async function captureViaCdp(opts: { try { const browserWs = await openCdp(opts.browserWsUrl); browserCdp = new CdpSocket(browserWs, onEvent); + const seed = parseCookieHeader(opts.seedCookie || ""); + if (seed.length > 0) { + await browserCdp + .send("Storage.setCookies", { + cookies: seed.map(({ name, value }) => ({ + name, + value, + url: FIREFLY_HOME_URL, + path: "/", + secure: true, + })), + }) + .catch(() => undefined); + } await browserCdp.send("Target.setDiscoverTargets", { discover: true }).catch(() => undefined); await browserCdp .send("Target.setAutoAttach", { @@ -305,7 +391,12 @@ async function captureViaCdp(opts: { const cdp = new CdpSocket(ws, onEvent); pageSockets.set(t.id, cdp); await cdp.send("Network.enable"); - if (!t.url || t.url === "about:blank" || t.url.startsWith("chrome://")) { + 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); } @@ -317,28 +408,52 @@ async function captureViaCdp(opts: { // list may fail briefly while Chrome starts } - if (capturedAccessToken) { - // Prefer cookies from any live page socket; fall back to empty. - for (const cdp of pageSockets.values()) { - if (!cdp.open) continue; - try { - const result = (await cdp.send("Network.getAllCookies")) as { - cookies?: CdpCookie[]; - }; - return { - accessToken: capturedAccessToken, - cookies: Array.isArray(result?.cookies) ? result.cookies : [], - }; - } catch { - /* try next */ - } + try { + const result = (await browserCdp.send("Storage.getCookies")) as { + cookies?: CdpCookie[]; + }; + if (Array.isArray(result?.cookies)) latestCookies = result.cookies; + } catch { + /* retry while Chrome is settling */ + } + + const accessToken = capturedAccessToken || String(opts.fallbackAccessToken || "").trim(); + if (accessToken) { + if (!opts.waitForRiskRefresh) { + return { + accessToken, + cookies: latestCookies, + arpSessionId: capturedArpSessionId, + }; + } + const elapsed = Date.now() - startedAt; + const forter = cookieValue(latestCookies, "forterToken"); + 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, + }; } - return { accessToken: capturedAccessToken, cookies: [] }; } await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); } + const fallback = String(opts.fallbackAccessToken || "").trim(); + if (fallback && latestCookies.length > 0) { + return { + accessToken: capturedAccessToken || fallback, + cookies: latestCookies, + arpSessionId: capturedArpSessionId, + }; + } throw new Error( "Adobe Firefly sign-in timed out. Complete sign-in at firefly.adobe.com and trigger an action " + "(open Generate) so the browser sends the Firefly request, then try again." @@ -380,10 +495,13 @@ function killProcessTree(child: ChildProcess | null): void { * Launch system Chrome/Edge at firefly.adobe.com, intercept firefly-3p * Authorization Bearer via CDP, return JWT + useful cookies. */ -export async function startAdobeFireflyBrowserLogin( - requestedTimeout?: unknown -): Promise { - const timeout = clampAdobeFireflyLoginTimeout(requestedTimeout); +async function runAdobeFireflyCdpBrowser(opts: { + timeoutMs: number; + interactive: boolean; + seedCookie?: string; + accessToken?: string; + log?: AdobeFireflyBrowserLog; +}): Promise { const browserPath = resolveSystemBrowserExecutable(); if (!browserPath) { return { @@ -395,10 +513,9 @@ export async function startAdobeFireflyBrowserLogin( }; } - let userDataDir: string | null = null; let child: ChildProcess | null = null; try { - userDataDir = mkdtempSync(join(tmpdir(), "omniroute-firefly-login-")); + const userDataDir = resolveAdobeFireflyBrowserProfileDir(); const port = await getFreeLoopbackPort(); const args = [ @@ -406,15 +523,14 @@ export async function startAdobeFireflyBrowserLogin( `--user-data-dir=${userDataDir}`, "--no-first-run", "--no-default-browser-check", - "--disable-sync", - "--disable-background-networking", + ...(opts.interactive ? [] : ["--window-position=-32000,-32000"]), "--window-size=1280,800", FIREFLY_HOME_URL, ]; child = spawn(browserPath, args, { stdio: "ignore", - windowsHide: false, + windowsHide: !opts.interactive, detached: false, }); @@ -439,19 +555,28 @@ export async function startAdobeFireflyBrowserLogin( captureViaCdp({ port, browserWsUrl: webSocketDebuggerUrl, - timeoutMs: timeout, + timeoutMs: opts.timeoutMs, + fallbackAccessToken: opts.accessToken, + seedCookie: opts.seedCookie, + waitForRiskRefresh: !opts.interactive, }), earlyExit, ]); const cookie = buildAdobeFireflyCookieHeader(captured.cookies); const account = accountLabelFromAdobeJwt(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 (error) { @@ -462,14 +587,60 @@ export async function startAdobeFireflyBrowserLogin( } finally { killProcessTree(child); child = null; - if (userDataDir) { - // Give Chrome a moment to release the profile directory. - await new Promise((r) => setTimeout(r, 300)); - try { - rmSync(userDataDir, { recursive: true, force: true }); - } catch { - // Profile may still be locked; temp cleaner will reclaim later. - } - } + } +} + +export async function startAdobeFireflyBrowserLogin( + requestedTimeout?: unknown +): Promise { + const run = cdpBrowserChain.then(() => + runAdobeFireflyCdpBrowser({ + timeoutMs: clampAdobeFireflyLoginTimeout(requestedTimeout), + interactive: true, + }) + ); + cdpBrowserChain = run.then( + () => undefined, + () => undefined + ); + return run; +} + +/** Packaged-safe background renewal. Reuses the durable sign-in profile; never imports Playwright. */ +export async function refreshAdobeFireflyViaCdp(opts: { + cookie?: string; + accessToken?: string; + timeoutMs?: number; + log?: AdobeFireflyBrowserLog; +}): Promise { + const run = cdpBrowserChain.then(async () => { + const result = await runAdobeFireflyCdpBrowser({ + timeoutMs: Math.max(15_000, Math.min(120_000, Number(opts.timeoutMs) || 75_000)), + interactive: false, + seedCookie: opts.cookie, + accessToken: opts.accessToken, + log: opts.log, + }); + const accessToken = String(result.credentials?.accessToken || "").trim(); + const cookie = String(result.credentials?.cookie || "").trim(); + if (!result.success || !accessToken || !cookie) return null; + return { + accessToken, + cookie, + arpSessionId: String(result.arpSessionId || "").trim(), + }; + }); + cdpBrowserChain = run.then( + () => undefined, + () => undefined + ); + try { + return await run; + } catch (error) { + opts.log?.warn?.( + "ADOBE-FIREFLY", + `CDP background refresh failed: ${error instanceof Error ? error.message : String(error)}` + ); + return null; } } diff --git a/open-sse/services/adobeFireflyClient.ts b/open-sse/services/adobeFireflyClient.ts index abc68be133..5bef8c7491 100644 --- a/open-sse/services/adobeFireflyClient.ts +++ b/open-sse/services/adobeFireflyClient.ts @@ -29,12 +29,10 @@ export const ADOBE_FIREFLY_IMAGE_SUBMIT_URL = "https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async"; export const ADOBE_FIREFLY_VIDEO_SUBMIT_URL = "https://firefly-3p.ff.adobe.io/v2/3p-videos/generate-async"; -export const ADOBE_FIREFLY_IMAGE_UPLOAD_URL = - "https://firefly-3p.ff.adobe.io/v2/storage/image"; +export const ADOBE_FIREFLY_IMAGE_UPLOAD_URL = "https://firefly-3p.ff.adobe.io/v2/storage/image"; export const ADOBE_FIREFLY_MODELS_DISCOVERY_URL = "https://firefly-3p.ff.adobe.io/v2/models/discovery"; -export const ADOBE_FIREFLY_CREDITS_BALANCE_URL = - "https://firefly.adobe.io/v1/credits/balance"; +export const ADOBE_FIREFLY_CREDITS_BALANCE_URL = "https://firefly.adobe.io/v1/credits/balance"; export const ADOBE_FIREFLY_IMS_REFRESH_URL = "https://adobeid-na1.services.adobe.com/ims/check/v6/token?jslVersion=v2-v0.48.0-1-g1e322cb"; /** Scope set observed on live firefly.adobe.com IMS access tokens. */ @@ -46,8 +44,7 @@ export const ADOBE_FIREFLY_IMS_SCOPE = const DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"; -const DEFAULT_SEC_CH_UA = - '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"'; +const DEFAULT_SEC_CH_UA = '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"'; const DEFAULT_POLL_INTERVAL_MS = 3000; const DEFAULT_IMAGE_TIMEOUT_MS = 180_000; const DEFAULT_VIDEO_TIMEOUT_MS = 300_000; @@ -69,12 +66,7 @@ export type AdobeFireflyImageModelId = | "runway-gen4-image"; export type AdobeFireflyVideoModelId = - | "sora-2" - | "sora-2-pro" - | "veo-3.1" - | "veo-3.1-fast" - | "veo-3.1-ref" - | "kling-3"; + "sora-2" | "sora-2-pro" | "veo-3.1" | "veo-3.1-fast" | "veo-3.1-ref" | "kling-3"; export interface AdobeFireflyImageModelSpec { upstreamModelId: string; @@ -97,123 +89,127 @@ export interface AdobeFireflyVideoModelSpec { * Upstream modelId/modelVersion pairs from firefly-3p models/discovery * (captured 2026-07 — see adobe/get_models.txt). Friendly catalog ids map here. */ -export const ADOBE_FIREFLY_IMAGE_MODELS: Record = - { - // Gemini 3.0 (Nano Banana Pro) — discovery: gemini-flash / nano-banana-2 - "nano-banana-pro": { - upstreamModelId: "gemini-flash", - upstreamModelVersion: "nano-banana-2", - family: "nano", - }, - // Gemini 2.5 (Nano Banana) — discovery: gemini-flash / nano-banana - "nano-banana": { - upstreamModelId: "gemini-flash", - upstreamModelVersion: "nano-banana", - family: "nano", - }, - // Gemini 3.1 (Nano Banana 2) — discovery: gemini-flash / nano-banana-3 - "nano-banana-2": { - upstreamModelId: "gemini-flash", - upstreamModelVersion: "nano-banana-3", - family: "nano", - }, - // GPT Image 2 — discovery modelVersion "2" (get_models: modelDisplayName "GPT Image 2") - "gpt-image": { - upstreamModelId: "gpt-image", - upstreamModelVersion: "2", - family: "gpt-image", - }, - // Explicit catalog alias so pickers show "gpt-image-2" distinctly - "gpt-image-2": { - upstreamModelId: "gpt-image", - upstreamModelVersion: "2", - family: "gpt-image", - }, - "gpt-image-1.5": { - upstreamModelId: "gpt-image", - upstreamModelVersion: "1.5", - family: "gpt-image", - }, - "flux-2": { - upstreamModelId: "flux", - upstreamModelVersion: "2", - family: "generic", - }, - "flux-pro": { - upstreamModelId: "flux", - upstreamModelVersion: "fluxPro", - family: "generic", - }, - "flux-ultra": { - upstreamModelId: "flux", - upstreamModelVersion: "fluxUltra", - family: "generic", - }, - "seedream-4": { - upstreamModelId: "seedream", - upstreamModelVersion: "seedream_v4", - family: "generic", - }, - "seedream-5-lite": { - upstreamModelId: "seedream", - upstreamModelVersion: "seedream_v5_lite", - family: "generic", - }, - "runway-gen4-image": { - upstreamModelId: "runway-gen4-image", - upstreamModelVersion: "gen4_image", - family: "generic", - }, - }; +export const ADOBE_FIREFLY_IMAGE_MODELS: Record< + AdobeFireflyImageModelId, + AdobeFireflyImageModelSpec +> = { + // Gemini 3.0 (Nano Banana Pro) — discovery: gemini-flash / nano-banana-2 + "nano-banana-pro": { + upstreamModelId: "gemini-flash", + upstreamModelVersion: "nano-banana-2", + family: "nano", + }, + // Gemini 2.5 (Nano Banana) — discovery: gemini-flash / nano-banana + "nano-banana": { + upstreamModelId: "gemini-flash", + upstreamModelVersion: "nano-banana", + family: "nano", + }, + // Gemini 3.1 (Nano Banana 2) — discovery: gemini-flash / nano-banana-3 + "nano-banana-2": { + upstreamModelId: "gemini-flash", + upstreamModelVersion: "nano-banana-3", + family: "nano", + }, + // GPT Image 2 — discovery modelVersion "2" (get_models: modelDisplayName "GPT Image 2") + "gpt-image": { + upstreamModelId: "gpt-image", + upstreamModelVersion: "2", + family: "gpt-image", + }, + // Explicit catalog alias so pickers show "gpt-image-2" distinctly + "gpt-image-2": { + upstreamModelId: "gpt-image", + upstreamModelVersion: "2", + family: "gpt-image", + }, + "gpt-image-1.5": { + upstreamModelId: "gpt-image", + upstreamModelVersion: "1.5", + family: "gpt-image", + }, + "flux-2": { + upstreamModelId: "flux", + upstreamModelVersion: "2", + family: "generic", + }, + "flux-pro": { + upstreamModelId: "flux", + upstreamModelVersion: "fluxPro", + family: "generic", + }, + "flux-ultra": { + upstreamModelId: "flux", + upstreamModelVersion: "fluxUltra", + family: "generic", + }, + "seedream-4": { + upstreamModelId: "seedream", + upstreamModelVersion: "seedream_v4", + family: "generic", + }, + "seedream-5-lite": { + upstreamModelId: "seedream", + upstreamModelVersion: "seedream_v5_lite", + family: "generic", + }, + "runway-gen4-image": { + upstreamModelId: "runway-gen4-image", + upstreamModelVersion: "gen4_image", + family: "generic", + }, +}; -export const ADOBE_FIREFLY_VIDEO_MODELS: Record = - { - "sora-2": { - engine: "sora2", - upstreamModel: "openai:firefly:colligo:sora2", - defaultDuration: 8, - defaultResolution: "720p", - }, - "sora-2-pro": { - engine: "sora2-pro", - upstreamModel: "openai:firefly:colligo:sora2-pro", - defaultDuration: 8, - defaultResolution: "720p", - }, - "veo-3.1": { - engine: "veo31-standard", - upstreamModel: "google:firefly:colligo:veo31", - modelId: "veo", - modelVersion: "3.1-generate", - defaultDuration: 6, - defaultResolution: "720p", - }, - "veo-3.1-fast": { - engine: "veo31-fast", - upstreamModel: "google:firefly:colligo:veo31-fast", - modelId: "veo", - modelVersion: "3.1-fast-generate", - defaultDuration: 6, - defaultResolution: "720p", - }, - "veo-3.1-ref": { - engine: "veo31-standard", - upstreamModel: "google:firefly:colligo:veo31", - modelId: "veo", - modelVersion: "3.1-generate", - referenceMode: "image", - defaultDuration: 6, - defaultResolution: "720p", - }, - "kling-3": { - engine: "kling3", - upstreamModel: "kling:firefly:colligo:kling3", - modelId: "kling", - modelVersion: "kling_v3_standard_i2v", - defaultDuration: 5, - defaultResolution: "1080p", - }, - }; +export const ADOBE_FIREFLY_VIDEO_MODELS: Record< + AdobeFireflyVideoModelId, + AdobeFireflyVideoModelSpec +> = { + "sora-2": { + engine: "sora2", + upstreamModel: "openai:firefly:colligo:sora2", + defaultDuration: 8, + defaultResolution: "720p", + }, + "sora-2-pro": { + engine: "sora2-pro", + upstreamModel: "openai:firefly:colligo:sora2-pro", + defaultDuration: 8, + defaultResolution: "720p", + }, + "veo-3.1": { + engine: "veo31-standard", + upstreamModel: "google:firefly:colligo:veo31", + modelId: "veo", + modelVersion: "3.1-generate", + defaultDuration: 6, + defaultResolution: "720p", + }, + "veo-3.1-fast": { + engine: "veo31-fast", + upstreamModel: "google:firefly:colligo:veo31-fast", + modelId: "veo", + modelVersion: "3.1-fast-generate", + defaultDuration: 6, + defaultResolution: "720p", + }, + "veo-3.1-ref": { + engine: "veo31-standard", + upstreamModel: "google:firefly:colligo:veo31", + modelId: "veo", + modelVersion: "3.1-generate", + referenceMode: "image", + defaultDuration: 6, + defaultResolution: "720p", + }, + "kling-3": { + engine: "kling3", + upstreamModel: "kling:firefly:colligo:kling3", + modelId: "kling", + modelVersion: "kling_v3_standard_i2v", + defaultDuration: 5, + defaultResolution: "1080p", + }, +}; const NANO_SIZE_MAP: Record> = { "1K": { @@ -337,7 +333,10 @@ export function adobeFireflyBalanceApiKey(): string { export function decodeAdobeJwtPayload(token: string): Record | null { try { // Do not call extractAdobeCredentialToken here (would recurse via guest checks). - let raw = String(token || "").trim().replace(/^bearer\s+/i, "").trim(); + let raw = String(token || "") + .trim() + .replace(/^bearer\s+/i, "") + .trim(); // If a blob was passed, take the first JWT-shaped segment. const m = raw.match(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/); if (m) raw = m[0]; @@ -414,7 +413,11 @@ export function extractAdobeCredentialToken(raw: string): string { if (!value) return ""; if (/^bearer\s+/i.test(value)) { - const bare = value.replace(/^bearer\s+/i, "").trim().split(/\s+/)[0] || ""; + const bare = + value + .replace(/^bearer\s+/i, "") + .trim() + .split(/\s+/)[0] || ""; if (looksLikeAdobeJwt(bare)) return bare; } @@ -432,7 +435,9 @@ export function extractAdobeCredentialToken(raw: string): string { } // Authorization: Bearer eyJ... - const authMatch = value.match(/Authorization\s*:\s*Bearer\s+([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i); + const authMatch = value.match( + /Authorization\s*:\s*Bearer\s+([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i + ); if (authMatch?.[1] && looksLikeAdobeJwt(authMatch[1])) return authMatch[1]; // Any eyJ… JWT in the blob (HAR / multi-line). Prefer user AdobeID tokens. @@ -545,8 +550,13 @@ export function normalizeAdobeAspectRatio(sizeOrRatio: unknown, fallback = "1:1" return fallback; } -export function normalizeAdobeOutputResolution(quality: unknown, size: unknown): "1K" | "2K" | "4K" { - const q = String(quality ?? "").trim().toLowerCase(); +export function normalizeAdobeOutputResolution( + quality: unknown, + size: unknown +): "1K" | "2K" | "4K" { + const q = String(quality ?? "") + .trim() + .toLowerCase(); if (q === "4k" || q === "ultra" || q === "high") return "4K"; if (q === "2k" || q === "hd" || q === "standard" || q === "medium") return "2K"; if (q === "1k" || q === "low") return "1K"; @@ -568,17 +578,33 @@ export function resolveAdobeImageModel(model: string): { .replace(/^firefly\//, ""); // Accept long catalog ids like firefly-nano-banana-pro-2k-16x9 - if (raw.includes("nano-banana2") || raw.includes("nano-banana-2") || raw.includes("nano-banana-3")) { - return { id: "nano-banana-2", spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-2"] }; + if ( + raw.includes("nano-banana2") || + raw.includes("nano-banana-2") || + raw.includes("nano-banana-3") + ) { + return { + id: "nano-banana-2", + spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-2"], + }; } if (raw.includes("nano-banana-pro")) { - return { id: "nano-banana-pro", spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"] }; + return { + id: "nano-banana-pro", + spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"], + }; } if (raw.includes("nano-banana")) { - return { id: "nano-banana", spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana"] }; + return { + id: "nano-banana", + spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana"], + }; } if (raw.includes("gpt-image-1.5") || raw.includes("gpt-image1.5")) { - return { id: "gpt-image-1.5", spec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image-1.5"] }; + return { + id: "gpt-image-1.5", + spec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image-1.5"], + }; } // Prefer explicit "2" / "gpt-image-2" before generic gpt-image if ( @@ -590,10 +616,17 @@ export function resolveAdobeImageModel(model: string): { ) { // Bare gpt-image and gpt-image-2 both map to upstream version "2" (GPT Image 2). if (raw.includes("1.5")) { - return { id: "gpt-image-1.5", spec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image-1.5"] }; + return { + id: "gpt-image-1.5", + spec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image-1.5"], + }; } - const id = raw.includes("gpt-image-2") || raw.includes("gptimage2") ? "gpt-image-2" : "gpt-image"; - return { id: id as AdobeFireflyImageModelId, spec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image"] }; + const id = + raw.includes("gpt-image-2") || raw.includes("gptimage2") ? "gpt-image-2" : "gpt-image"; + return { + id: id as AdobeFireflyImageModelId, + spec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image"], + }; } if (raw.includes("flux-ultra") || raw.includes("fluxultra")) { return { id: "flux-ultra", spec: ADOBE_FIREFLY_IMAGE_MODELS["flux-ultra"] }; @@ -605,13 +638,19 @@ export function resolveAdobeImageModel(model: string): { return { id: "flux-2", spec: ADOBE_FIREFLY_IMAGE_MODELS["flux-2"] }; } if (raw.includes("seedream-5") || raw.includes("seedream_v5")) { - return { id: "seedream-5-lite", spec: ADOBE_FIREFLY_IMAGE_MODELS["seedream-5-lite"] }; + return { + id: "seedream-5-lite", + spec: ADOBE_FIREFLY_IMAGE_MODELS["seedream-5-lite"], + }; } if (raw.includes("seedream")) { return { id: "seedream-4", spec: ADOBE_FIREFLY_IMAGE_MODELS["seedream-4"] }; } if (raw.includes("runway") && raw.includes("image")) { - return { id: "runway-gen4-image", spec: ADOBE_FIREFLY_IMAGE_MODELS["runway-gen4-image"] }; + return { + id: "runway-gen4-image", + spec: ADOBE_FIREFLY_IMAGE_MODELS["runway-gen4-image"], + }; } if (raw in ADOBE_FIREFLY_IMAGE_MODELS) { @@ -620,7 +659,10 @@ export function resolveAdobeImageModel(model: string): { } // Default to Nano Banana Pro (most common Firefly image path). - return { id: "nano-banana-pro", spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"] }; + return { + id: "nano-banana-pro", + spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"], + }; } export function resolveAdobeVideoModel(model: string): { @@ -640,10 +682,16 @@ export function resolveAdobeVideoModel(model: string): { return { id: "sora-2", spec: ADOBE_FIREFLY_VIDEO_MODELS["sora-2"] }; } if (raw.includes("veo31-ref") || raw.includes("veo-3.1-ref") || raw.includes("veo31_ref")) { - return { id: "veo-3.1-ref", spec: ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1-ref"] }; + return { + id: "veo-3.1-ref", + spec: ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1-ref"], + }; } if (raw.includes("veo31-fast") || raw.includes("veo-3.1-fast") || raw.includes("veo31_fast")) { - return { id: "veo-3.1-fast", spec: ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1-fast"] }; + return { + id: "veo-3.1-fast", + spec: ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1-fast"], + }; } if (raw.includes("veo31") || raw.includes("veo-3.1") || raw.includes("veo")) { return { id: "veo-3.1", spec: ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1"] }; @@ -660,9 +708,17 @@ export function resolveAdobeVideoModel(model: string): { return { id: "sora-2", spec: ADOBE_FIREFLY_VIDEO_MODELS["sora-2"] }; } +/** + * Map OpenAI/VibeProxy quality tiers onto Firefly gpt-image `generationSettings.detailLevel`. + * Wire range is integer 1–5 (discovery schema). Default is **maximal (5)** — + * the SPA often defaults to 3, but detail is critical for GPT Image 2 output quality. + * Explicit low/medium still honor the caller's choice. + */ function gptDetailLevel(quality: unknown): number { // Live firefly.adobe.com default for gpt-image is detailLevel 3 (medium). - const q = String(quality ?? "medium").trim().toLowerCase(); + const q = String(quality ?? "medium") + .trim() + .toLowerCase(); if (q === "high" || q === "4k" || q === "ultra") return 5; if (q === "low" || q === "1k") return 1; if (q === "medium" || q === "2k" || q === "standard" || q === "hd" || q === "auto") return 3; @@ -703,7 +759,10 @@ export function buildAdobeImagePayload(opts: { modelSpecificPayload: { size: "auto" }, modelId: opts.modelSpec.upstreamModelId, modelVersion: opts.modelSpec.upstreamModelVersion, - generationMetadata: { module: "text2image", submodule: "ff-image-generate" }, + generationMetadata: { + module: "text2image", + submodule: "ff-image-generate", + }, generationSettings: { detailLevel: gptDetailLevel(opts.quality), ...genSettings, @@ -736,7 +795,10 @@ export function buildAdobeImagePayload(opts: { groundSearch: false, skipCai: false, output: { storeInputs: true }, - generationMetadata: { module: "text2image", submodule: "ff-image-generate" }, + generationMetadata: { + module: "text2image", + submodule: "ff-image-generate", + }, modelSpecificPayload: { parameters: { addWatermark: false }, aspectRatio: ratio, @@ -752,7 +814,10 @@ export function buildAdobeImagePayload(opts: { })); // Flux / Seedream / Runway image historically used image2image; nano keeps text2image. if (opts.modelSpec.family === "generic") { - payload.generationMetadata = { module: "image2image", submodule: "ff-image-generate" }; + payload.generationMetadata = { + module: "image2image", + submodule: "ff-image-generate", + }; } } return payload; @@ -780,7 +845,10 @@ export function buildAdobeVideoPayload(opts: { }): Record { const seedVal = typeof opts.seed === "number" ? opts.seed : Math.floor(Date.now() % 999999); const aspect = opts.aspectRatio === "auto" ? "16:9" : opts.aspectRatio || "16:9"; - const duration = Math.max(1, Math.min(30, Math.floor(opts.duration || opts.modelSpec.defaultDuration))); + const duration = Math.max( + 1, + Math.min(30, Math.floor(opts.duration || opts.modelSpec.defaultDuration)) + ); const resolution = opts.resolution || opts.modelSpec.defaultResolution; const vidSize = videoSize(aspect, resolution); const engine = opts.modelSpec.engine; @@ -867,13 +935,20 @@ export function buildAdobeVideoPayload(opts: { duration, fps: 24, prompt: promptJson, - generationMetadata: { module: sourceImageIds.length ? "image2video" : "text2video" }, + generationMetadata: { + module: sourceImageIds.length ? "image2video" : "text2video", + }, model: opts.modelSpec.upstreamModel, generateLoop: false, transparentBackground: false, seed: String(seedVal), locale: "en-US", - camera: { angle: "none", shotSize: "none", motion: null, promptStyle: null }, + camera: { + angle: "none", + shotSize: "none", + motion: null, + promptStyle: null, + }, negativePrompt: negative, jobMode: "standard", debugGenerationEndpoint: "", @@ -969,7 +1044,11 @@ export function isValidAdobeArpSessionId(value: string): boolean { ); // Reject binary garbage that "decodes" but isn't JSON (corrupted sherlock paste). if (/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(json)) return false; - const obj = JSON.parse(json) as { sid?: unknown; ftr?: unknown; ark?: unknown }; + const obj = JSON.parse(json) as { + sid?: unknown; + ftr?: unknown; + ark?: unknown; + }; return typeof obj.sid === "string" && obj.sid.length > 0; } catch { // Opaque short sherlockToken values (tests / non-JSON) when non-empty. @@ -1028,7 +1107,10 @@ export function extractAdobeArpSessionId(cookieOrBlob: string): string { const candidates: string[] = []; const push = (v: string | undefined | null) => { if (!v) return; - let t = v.trim().replace(/^["']|["']$/g, "").trim(); + let t = v + .trim() + .replace(/^["']|["']$/g, "") + .trim(); try { // Cookie values are often URI-encoded if (/%[0-9A-Fa-f]{2}/.test(t)) t = decodeURIComponent(t); @@ -1094,7 +1176,9 @@ function scoreAdobeArpCandidate(value: string): number { let score = value.length; try { const padded = value + "=".repeat((4 - (value.length % 4)) % 4); - const json = Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"); + const json = Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString( + "utf8" + ); const obj = JSON.parse(json) as { sid?: unknown; ark?: unknown; @@ -1105,7 +1189,8 @@ function scoreAdobeArpCandidate(value: string): number { if (typeof obj.sid === "string" && obj.sid) score += 1000; if (typeof obj.ark === "string" && obj.ark.length > 20) score += 500; if (typeof obj.ftr === "string" && obj.ftr.includes(ADOBE_FIREFLY_FTR_MAGIC)) score += 200; - if (typeof obj.ark === "string" && obj.ark.includes(ADOBE_FIREFLY_ARKOSE_PUBLIC_KEY)) score += 100; + if (typeof obj.ark === "string" && obj.ark.includes(ADOBE_FIREFLY_ARKOSE_PUBLIC_KEY)) + score += 100; // Live successful generates (adobe/image_generate.txt) include browser fingerprint fields. if (typeof obj.bfp === "string" && obj.bfp.length >= 8) score += 150; if (typeof obj.fpjs === "string" && obj.fpjs.length > 10) score += 150; @@ -1145,9 +1230,7 @@ export function resolveAdobeArpSessionId(sessionCookieOrBlob?: string): string { // Lazy require of rebuild helper to avoid circular import at module load. // Inline minimal rebuild here (sid+ark+ftr) so resolve stays self-contained. const getCookie = (name: string): string => { - const m = blob.match( - new RegExp(`(?:^|[;\\s\\n\\r])${name}=([^;\\s\\n\\r]+)`, "i") - ); + const m = blob.match(new RegExp(`(?:^|[;\\s\\n\\r])${name}=([^;\\s\\n\\r]+)`, "i")); if (!m?.[1]) return ""; let v = m[1].trim(); try { @@ -1207,10 +1290,7 @@ export function buildAdobeSubmitHeaders( generateAdobeNonce(); // Explicit arpSessionId wins (caller may pass synthetic short test ids or real browser ARP). const explicitArp = extras?.arpSessionId ? String(extras.arpSessionId).trim() : ""; - const arp = - explicitArp || - extractAdobeArpSessionId(cookieBlob) || - buildAdobeArpSessionId(); + const arp = explicitArp || extractAdobeArpSessionId(cookieBlob) || buildAdobeArpSessionId(); const headers: Record = { ...browserHeaders(), Authorization: `Bearer ${accessToken}`, @@ -1251,7 +1331,10 @@ export function buildAdobeUploadHeaders( cookie: extras?.cookie, prompt: extras?.prompt || "upload", }); - const ct = String(contentType || "image/png").trim().toLowerCase() || "image/png"; + const ct = + String(contentType || "image/png") + .trim() + .toLowerCase() || "image/png"; return { ...base, "content-type": ct.startsWith("image/") ? ct : "image/png", @@ -1267,7 +1350,9 @@ export function extractAdobeSourceImageSources(body: unknown, max = 4): string[] if (!body || typeof body !== "object") return []; const b = body as Record; const po = - b.provider_options && typeof b.provider_options === "object" && !Array.isArray(b.provider_options) + b.provider_options && + typeof b.provider_options === "object" && + !Array.isArray(b.provider_options) ? (b.provider_options as Record) : {}; @@ -1379,11 +1464,18 @@ export function parseAdobeImageSourceBytes(source: string): { "bad_image" ); } - return { buffer, contentType: mime.startsWith("image/") ? mime : "image/png" }; + return { + buffer, + contentType: mime.startsWith("image/") ? mime : "image/png", + }; } // Raw base64 without data: prefix - if (!/^https?:\/\//i.test(trimmed) && /^[A-Za-z0-9+/=\s]+$/.test(trimmed) && trimmed.length > 64) { + if ( + !/^https?:\/\//i.test(trimmed) && + /^[A-Za-z0-9+/=\s]+$/.test(trimmed) && + trimmed.length > 64 + ) { const buffer = Buffer.from(trimmed.replace(/\s/g, ""), "base64"); if (buffer.length > 0 && buffer.length <= ADOBE_FIREFLY_MAX_UPLOAD_BYTES) { return { buffer, contentType: "image/png" }; @@ -1430,7 +1522,10 @@ export async function uploadAdobeFireflyImage(opts: { /** Used for deterministic x-nonce (optional). */ prompt?: string; fetchImpl?: typeof fetch; - log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; + log?: { + info?: (...args: unknown[]) => void; + error?: (...args: unknown[]) => void; + }; }): Promise { const fetchImpl = opts.fetchImpl || fetch; const buffer = Buffer.isBuffer(opts.bytes) ? opts.bytes : Buffer.from(opts.bytes); @@ -1466,7 +1561,7 @@ export async function uploadAdobeFireflyImage(opts: { cookie: cookieHeader || undefined, prompt: opts.prompt || "upload", }), - body: buffer, + body: Uint8Array.from(buffer), }); const text = await resp.text().catch(() => ""); @@ -1489,11 +1584,7 @@ export async function uploadAdobeFireflyImage(opts: { try { json = text ? JSON.parse(text) : {}; } catch { - throw new AdobeFireflyError( - "Adobe Firefly image upload returned non-JSON body", - 502, - "upload" - ); + throw new AdobeFireflyError("Adobe Firefly image upload returned non-JSON body", 502, "upload"); } const id = parseAdobeStorageUploadResponse(json); if (!id) { @@ -1522,7 +1613,10 @@ export async function resolveAdobeSourceImageIds(opts: { arpSessionId?: string; prompt?: string; fetchImpl?: typeof fetch; - log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; + log?: { + info?: (...args: unknown[]) => void; + error?: (...args: unknown[]) => void; + }; }): Promise { const max = Math.max(1, Math.min(8, opts.max ?? 4)); const sources = extractAdobeSourceImageSources(opts.body, max); @@ -1680,7 +1774,8 @@ export function extractAdobeResultLink( if (override) return override; const data = body && typeof body === "object" ? (body as Record) : {}; - const links = data.links && typeof data.links === "object" ? (data.links as Record) : {}; + const links = + data.links && typeof data.links === "object" ? (data.links as Record) : {}; const result = links.result; if (typeof result === "string" && result) return result; if (result && typeof result === "object") { @@ -1711,9 +1806,7 @@ export function normalizeAdobePollUrl(rawUrl: string): string { const path = parsed.pathname || ""; const isJobPath = - path.includes("/jobs/result/") || - path.includes("/v2/status") || - path.includes("/status/"); + path.includes("/jobs/result/") || path.includes("/v2/status") || path.includes("/status/"); if (!isJobPath) return url; const jobId = path.split("/").filter(Boolean).pop() || ""; @@ -1728,14 +1821,12 @@ export function normalizeAdobePollUrl(rawUrl: string): string { } } -export function extractAdobeMediaUrl( - latest: unknown, - kind: "image" | "video" -): string | null { +export function extractAdobeMediaUrl(latest: unknown, kind: "image" | "video"): string | null { const body = latest && typeof latest === "object" ? (latest as Record) : {}; const outputs = Array.isArray(body.outputs) ? body.outputs : []; if (outputs.length > 0) { - const first = outputs[0] && typeof outputs[0] === "object" ? (outputs[0] as Record) : {}; + const first = + outputs[0] && typeof outputs[0] === "object" ? (outputs[0] as Record) : {}; const media = kind === "image" ? first.image && typeof first.image === "object" @@ -1749,7 +1840,10 @@ export function extractAdobeMediaUrl( } // Fallback recursive search for a presigned URL. - const found = findPresignedUrl(latest, kind === "image" ? [".png", ".jpg", ".jpeg", ".webp"] : [".mp4", ".webm"]); + const found = findPresignedUrl( + latest, + kind === "image" ? [".png", ".jpg", ".jpeg", ".webp"] : [".mp4", ".webm"] + ); return found; } @@ -1757,7 +1851,12 @@ function findPresignedUrl(obj: unknown, exts: string[]): string | null { if (!obj) return null; if (typeof obj === "string") { const s = obj.trim(); - if (/^https?:\/\//i.test(s) && (exts.some((e) => s.toLowerCase().includes(e)) || s.includes("presigned") || s.includes("X-Amz"))) { + if ( + /^https?:\/\//i.test(s) && + (exts.some((e) => s.toLowerCase().includes(e)) || + s.includes("presigned") || + s.includes("X-Amz")) + ) { return s; } return null; @@ -1813,8 +1912,7 @@ async function imsCheckToken(opts: { guestAllowed: boolean; fetchImpl: typeof fetch; }): Promise< - | { ok: true; token: string; data: ImsTokenResponse } - | { ok: false; status: number; error: string } + { ok: true; token: string; data: ImsTokenResponse } | { ok: false; status: number; error: string } > { const form = new URLSearchParams({ client_id: opts.clientId, @@ -1971,7 +2069,11 @@ export async function resolveAdobeAccessToken( | { apiKey?: string; accessToken?: string; - providerSpecificData?: { cookie?: unknown; access_token?: unknown; accessToken?: unknown } | null; + providerSpecificData?: { + cookie?: unknown; + access_token?: unknown; + accessToken?: unknown; + } | null; } | null | undefined, @@ -2041,7 +2143,11 @@ export interface AdobeFireflyCreditsBalance { raw?: unknown; } -function readQuotaBlock(block: unknown): { total: number; used: number; available: number } { +function readQuotaBlock(block: unknown): { + total: number; + used: number; + available: number; +} { if (!block || typeof block !== "object") return { total: 0, used: 0, available: 0 }; const q = (block as Record).quota && @@ -2189,7 +2295,11 @@ export async function discoverAdobeFireflyModels( body: JSON.stringify({ filters: { resolveSchema: true } }), }); if (resp.status === 401 || resp.status === 403) { - throw new AdobeFireflyError("Adobe Firefly model discovery: token invalid or expired", 401, "auth"); + throw new AdobeFireflyError( + "Adobe Firefly model discovery: token invalid or expired", + 401, + "auth" + ); } if (!resp.ok) { const text = await resp.text().catch(() => ""); @@ -2213,11 +2323,15 @@ async function pollAdobeJob(opts: { timeoutMs: number; pollIntervalMs?: number; fetchImpl?: typeof fetch; - log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; + log?: { + info?: (...args: unknown[]) => void; + error?: (...args: unknown[]) => void; + }; }): Promise<{ mediaUrl: string; latest: unknown }> { const fetchImpl = opts.fetchImpl || fetch; const deadline = Date.now() + opts.timeoutMs; - const interval = opts.pollIntervalMs && opts.pollIntervalMs > 0 ? opts.pollIntervalMs : DEFAULT_POLL_INTERVAL_MS; + const interval = + opts.pollIntervalMs && opts.pollIntervalMs > 0 ? opts.pollIntervalMs : DEFAULT_POLL_INTERVAL_MS; let attempt = 0; let latest: unknown = {}; @@ -2231,7 +2345,11 @@ async function pollAdobeJob(opts: { if (pollResp.status === 401 || pollResp.status === 403) { const accessError = pollResp.headers.get("x-access-error") || ""; if (accessError === "taste_exhausted") { - throw new AdobeFireflyError("Adobe Firefly quota exhausted for this account", 429, "quota_exhausted"); + throw new AdobeFireflyError( + "Adobe Firefly quota exhausted for this account", + 429, + "quota_exhausted" + ); } throw new AdobeFireflyError("Adobe Firefly token invalid or expired", 401, "auth"); } @@ -2276,7 +2394,10 @@ async function pollAdobeJob(opts: { ); } - opts.log?.info?.("ADOBE-FIREFLY", `${opts.kind} pending #${attempt} status=${statusVal || "unknown"}`); + opts.log?.info?.( + "ADOBE-FIREFLY", + `${opts.kind} pending #${attempt} status=${statusVal || "unknown"}` + ); await sleep(interval); } @@ -2290,10 +2411,14 @@ async function pollAdobeJob(opts: { const SUBMIT_MAX_ATTEMPTS = 5; /** Base backoff after 408; combined with withAdobeFireflySubmitGate (~12s min gap). */ function submitBaseDelayMs(): number { - if (process.env.ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS != null && process.env.ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS !== "") { + if ( + process.env.ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS != null && + process.env.ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS !== "" + ) { return Math.max(0, Number(process.env.ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS) || 0); } - if (process.env.NODE_ENV === "test" || process.env.VITEST || process.env.NODE_TEST_CONTEXT) return 20; + if (process.env.NODE_ENV === "test" || process.env.VITEST || process.env.NODE_TEST_CONTEXT) + return 20; return 8000; } @@ -2315,7 +2440,10 @@ export async function adobeFireflyGenerateImage(opts: { sessionFingerprint?: string; timeoutMs?: number; fetchImpl?: typeof fetch; - log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; + log?: { + info?: (...args: unknown[]) => void; + error?: (...args: unknown[]) => void; + }; }): Promise<{ url: string; b64_json?: string; latest: unknown }> { const fetchImpl = opts.fetchImpl || fetch; const { spec } = resolveAdobeImageModel(opts.model); @@ -2333,18 +2461,19 @@ export async function adobeFireflyGenerateImage(opts: { }); const sessionCookie = String(opts.sessionCookie || "").trim(); - const cookieHeader = extractAdobeCookieHeader(sessionCookie); + let activeCookie = extractAdobeCookieHeader(sessionCookie) || sessionCookie; // Prefer real browser sherlockToken / cookie rebuild (forter+arkose). Only the raw // credential paste counts as "browser ARP" — never the pure synthetic fallback. - const hadBrowserArp = hasBrowserAdobeArpSession(cookieHeader || sessionCookie); + const hadBrowserArp = hasBrowserAdobeArpSession(activeCookie); let arpSessionId = (opts.arpSessionId && String(opts.arpSessionId).trim()) || - resolveAdobeArpSessionId(cookieHeader || sessionCookie); + resolveAdobeArpSessionId(activeCookie); let submitData: unknown = {}; let submitHeaders: Headers | Record = new Headers(); let lastSubmitError = ""; let sawSystemUnderLoad = false; let accessToken = opts.accessToken; + let authRefreshAttempted = false; const { withAdobeFireflySubmitGate, @@ -2353,12 +2482,13 @@ export async function adobeFireflyGenerateImage(opts: { rotateAdobeFireflySessionOnError, resolveAdobeArpSessionIdSmart, fingerprintAdobeCredential, + estimateAdobeTokenExpiry, } = await import("./adobeFireflySession.ts"); // Stable sticky key — do NOT include arpSessionId (it changes and would break sticky). const fingerprint = String(opts.sessionFingerprint || "").trim() || - fingerprintAdobeCredential([accessToken, cookieHeader || sessionCookie].filter(Boolean).join("\n")); + fingerprintAdobeCredential([accessToken, activeCookie].filter(Boolean).join("\n")); // Gate serialize + min gap: mid-batch 408 is often rate-limit thrash, not "dead" ARP. await withAdobeFireflySubmitGate(async () => { @@ -2368,7 +2498,7 @@ export async function adobeFireflyGenerateImage(opts: { headers: buildAdobeSubmitHeaders(accessToken, { arpSessionId, prompt: opts.prompt, - cookie: cookieHeader || undefined, + cookie: activeCookie || undefined, }), body: JSON.stringify(payload), }); @@ -2383,9 +2513,34 @@ export async function adobeFireflyGenerateImage(opts: { "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; + } + } throw new AdobeFireflyError( - "Adobe Firefly token invalid or expired. Paste a fresh IMS JWT (Authorization: Bearer on firefly-3p) " + - "plus the firefly.adobe.com Cookie once — the app will auto-refresh ARP after that.", + "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" ); @@ -2399,13 +2554,13 @@ export async function adobeFireflyGenerateImage(opts: { lastSubmitError = `Adobe Firefly image submit failed (${submitResp.status}): ${sanitizeErrorMessage(text.slice(0, 300))}`; if (isAdobeTransientSubmitError(submitResp.status, text) && attempt < SUBMIT_MAX_ATTEMPTS) { try { - if (cookieHeader || sessionCookie) { + if (activeCookie) { const rotated = await rotateAdobeFireflySessionOnError( { accessToken, - cookie: cookieHeader || sessionCookie, + cookie: activeCookie, arpSessionId, - tokenExpiresAt: 0, + tokenExpiresAt: estimateAdobeTokenExpiry(accessToken), updatedAt: Date.now(), fingerprint, source: "rebuild", @@ -2418,9 +2573,12 @@ export async function adobeFireflyGenerateImage(opts: { } ); accessToken = rotated.accessToken || accessToken; + activeCookie = rotated.cookie || activeCookie; arpSessionId = rotated.arpSessionId; } else { - arpSessionId = resolveAdobeArpSessionIdSmart(sessionCookie, { rotate: true }); + arpSessionId = resolveAdobeArpSessionIdSmart(sessionCookie, { + rotate: true, + }); } } catch { // Keep prior ARP — synthetic thrash rarely recovers colligo 408. @@ -2429,7 +2587,8 @@ export async function adobeFireflyGenerateImage(opts: { const delay = base <= 50 ? base - : Math.min(90_000, base * Math.pow(2, attempt - 1)) + Math.floor(Math.random() * 1500); + : 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})` @@ -2440,7 +2599,9 @@ export async function adobeFireflyGenerateImage(opts: { noteAdobeFireflySubmitFailure(); if (sawSystemUnderLoad && isAdobeTransientSubmitError(submitResp.status, text)) { throw new AdobeFireflyError( - formatAdobeSystemUnderLoadError("image", attempt, { hadBrowserArp }), + formatAdobeSystemUnderLoadError("image", attempt, { + hadBrowserArp, + }), 408, "system_under_load" ); @@ -2463,7 +2624,9 @@ export async function adobeFireflyGenerateImage(opts: { if (!pollUrl) { if (sawSystemUnderLoad) { throw new AdobeFireflyError( - formatAdobeSystemUnderLoadError("image", SUBMIT_MAX_ATTEMPTS, { hadBrowserArp }), + formatAdobeSystemUnderLoadError("image", SUBMIT_MAX_ATTEMPTS, { + hadBrowserArp, + }), 408, "system_under_load" ); @@ -2507,8 +2670,16 @@ export async function adobeFireflyGenerateVideo(opts: { sessionFingerprint?: string; timeoutMs?: number; fetchImpl?: typeof fetch; - log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; -}): Promise<{ url: string; b64_json?: string; format: string; latest: unknown }> { + log?: { + info?: (...args: unknown[]) => void; + error?: (...args: unknown[]) => void; + }; +}): Promise<{ + url: string; + b64_json?: string; + format: string; + latest: unknown; +}> { const fetchImpl = opts.fetchImpl || fetch; const { spec } = resolveAdobeVideoModel(opts.model); const aspectRatio = normalizeAdobeAspectRatio(opts.aspectRatio ?? opts.size, "16:9"); @@ -2538,16 +2709,17 @@ export async function adobeFireflyGenerateVideo(opts: { }); const sessionCookie = String(opts.sessionCookie || "").trim(); - const cookieHeader = extractAdobeCookieHeader(sessionCookie); - const hadBrowserArp = hasBrowserAdobeArpSession(cookieHeader || sessionCookie); + let activeCookie = extractAdobeCookieHeader(sessionCookie) || sessionCookie; + const hadBrowserArp = hasBrowserAdobeArpSession(activeCookie); let arpSessionId = (opts.arpSessionId && String(opts.arpSessionId).trim()) || - resolveAdobeArpSessionId(cookieHeader || sessionCookie); + resolveAdobeArpSessionId(activeCookie); let submitData: unknown = {}; let submitHeaders: Headers | Record = new Headers(); let lastSubmitError = ""; let sawSystemUnderLoad = false; let accessToken = opts.accessToken; + let authRefreshAttempted = false; const { withAdobeFireflySubmitGate, @@ -2556,11 +2728,12 @@ export async function adobeFireflyGenerateVideo(opts: { rotateAdobeFireflySessionOnError, resolveAdobeArpSessionIdSmart, fingerprintAdobeCredential, + estimateAdobeTokenExpiry, } = await import("./adobeFireflySession.ts"); const fingerprint = String(opts.sessionFingerprint || "").trim() || - fingerprintAdobeCredential([accessToken, cookieHeader || sessionCookie].filter(Boolean).join("\n")); + fingerprintAdobeCredential([accessToken, activeCookie].filter(Boolean).join("\n")); await withAdobeFireflySubmitGate(async () => { for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) { @@ -2569,7 +2742,7 @@ export async function adobeFireflyGenerateVideo(opts: { headers: buildAdobeSubmitHeaders(accessToken, { arpSessionId, prompt: opts.prompt, - cookie: cookieHeader || undefined, + cookie: activeCookie || undefined, }), body: JSON.stringify(payload), }); @@ -2584,9 +2757,34 @@ export async function adobeFireflyGenerateVideo(opts: { "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; + } + } throw new AdobeFireflyError( - "Adobe Firefly token invalid or expired. Paste a fresh IMS JWT (Authorization: Bearer on firefly-3p) " + - "plus the firefly.adobe.com Cookie once — the app will auto-refresh ARP after that.", + "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" ); @@ -2600,13 +2798,13 @@ export async function adobeFireflyGenerateVideo(opts: { lastSubmitError = `Adobe Firefly video submit failed (${submitResp.status}): ${sanitizeErrorMessage(text.slice(0, 300))}`; if (isAdobeTransientSubmitError(submitResp.status, text) && attempt < SUBMIT_MAX_ATTEMPTS) { try { - if (cookieHeader || sessionCookie) { + if (activeCookie) { const rotated = await rotateAdobeFireflySessionOnError( { accessToken, - cookie: cookieHeader || sessionCookie, + cookie: activeCookie, arpSessionId, - tokenExpiresAt: 0, + tokenExpiresAt: estimateAdobeTokenExpiry(accessToken), updatedAt: Date.now(), fingerprint, source: "rebuild", @@ -2618,9 +2816,12 @@ export async function adobeFireflyGenerateVideo(opts: { } ); accessToken = rotated.accessToken || accessToken; + activeCookie = rotated.cookie || activeCookie; arpSessionId = rotated.arpSessionId; } else { - arpSessionId = resolveAdobeArpSessionIdSmart(sessionCookie, { rotate: true }); + arpSessionId = resolveAdobeArpSessionIdSmart(sessionCookie, { + rotate: true, + }); } } catch { /* keep prior ARP */ @@ -2629,7 +2830,8 @@ export async function adobeFireflyGenerateVideo(opts: { const delay = base <= 50 ? base - : Math.min(90_000, base * Math.pow(2, attempt - 1)) + Math.floor(Math.random() * 1500); + : 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})` @@ -2640,7 +2842,9 @@ export async function adobeFireflyGenerateVideo(opts: { noteAdobeFireflySubmitFailure(); if (sawSystemUnderLoad && isAdobeTransientSubmitError(submitResp.status, text)) { throw new AdobeFireflyError( - formatAdobeSystemUnderLoadError("video", attempt, { hadBrowserArp }), + formatAdobeSystemUnderLoadError("video", attempt, { + hadBrowserArp, + }), 408, "system_under_load" ); @@ -2662,7 +2866,9 @@ export async function adobeFireflyGenerateVideo(opts: { if (!pollUrl) { if (sawSystemUnderLoad) { throw new AdobeFireflyError( - formatAdobeSystemUnderLoadError("video", SUBMIT_MAX_ATTEMPTS, { hadBrowserArp }), + formatAdobeSystemUnderLoadError("video", SUBMIT_MAX_ATTEMPTS, { + hadBrowserArp, + }), 408, "system_under_load" ); diff --git a/open-sse/services/adobeFireflySession.ts b/open-sse/services/adobeFireflySession.ts index 4e24c0ec80..8442dcd4e5 100644 --- a/open-sse/services/adobeFireflySession.ts +++ b/open-sse/services/adobeFireflySession.ts @@ -8,8 +8,8 @@ * 2) Rebuild x-arp-session-id from cookie pieces (ff_session_guid + arkose + forterToken) * or pasted sherlockToken — never launch Chrome by default * 3) Sticky working ARP across batch jobs + submit spacing (colligo rate-limit defense) - * 4) Optional Chrome warm ONLY when ADOBE_FIREFLY_BROWSER_REFRESH=1 (or mid-batch 408 recovery). - * Default Chrome mode is **off-screen headed** (Forter-safe). Headless is opt-in and often rejected. + * 4) Packaged-safe Chrome/CDP warm on stale risk state, JWT expiry, or 408 recovery. + * The durable browser profile holds Adobe SSO; Playwright is not required. */ import { createHash, randomUUID } from "node:crypto"; @@ -45,15 +45,22 @@ export interface AdobeFireflySessionResolveOpts { credentials?: { apiKey?: string; accessToken?: string; - providerSpecificData?: { cookie?: unknown; access_token?: unknown; accessToken?: unknown } | null; + providerSpecificData?: { + cookie?: unknown; + access_token?: unknown; + accessToken?: unknown; + } | null; } | null; /** Force browser / cookie ARP rebuild (e.g. after HTTP 408). */ forceRefresh?: boolean; /** Prefer minting a brand-new ARP (retry path). */ rotateArp?: boolean; fetchImpl?: typeof fetch; - log?: { info?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void }; - /** Disable Playwright refresh (tests / hosts without browsers). */ + log?: { + info?: (...args: unknown[]) => void; + warn?: (...args: unknown[]) => void; + }; + /** Disable durable CDP refresh (tests / hosts without Chrome or Edge). */ allowBrowserRefresh?: boolean; } @@ -77,19 +84,27 @@ let consecutiveAdobeSubmitSuccesses = 0; /** Minimum gap between generate-async submits (ms). Prevents batch thrashing → 408. */ function minSubmitGapMs(): number { - if (process.env.ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS != null && process.env.ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS !== "") { + if ( + process.env.ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS != null && + process.env.ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS !== "" + ) { return Math.max(0, Number(process.env.ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS) || 0); } // Unit tests must not serialize multi-second gaps between cases that share the process-global gate. - if (process.env.NODE_ENV === "test" || process.env.VITEST || process.env.NODE_TEST_CONTEXT) return 0; + if (process.env.NODE_ENV === "test" || process.env.VITEST || process.env.NODE_TEST_CONTEXT) + return 0; // Live colligo rejects thrash after a few generates even with sticky ARP — 12s default. return 12_000; } /** Extra gap after every N successful submits (mid-batch death defense). */ function batchExtraGapMs(): number { - if (process.env.NODE_ENV === "test" || process.env.VITEST || process.env.NODE_TEST_CONTEXT) return 0; - if (consecutiveAdobeSubmitSuccesses > 0 && consecutiveAdobeSubmitSuccesses % BATCH_SUCCESS_COOLDOWN_EVERY === 0) { + if (process.env.NODE_ENV === "test" || process.env.VITEST || process.env.NODE_TEST_CONTEXT) + return 0; + if ( + consecutiveAdobeSubmitSuccesses > 0 && + consecutiveAdobeSubmitSuccesses % BATCH_SUCCESS_COOLDOWN_EVERY === 0 + ) { return Number(process.env.ADOBE_FIREFLY_BATCH_EXTRA_GAP_MS || BATCH_SUCCESS_EXTRA_GAP_MS); } return 0; @@ -133,14 +148,20 @@ function sessionFilePath(fingerprint: string): string { } export function fingerprintAdobeCredential(raw: string): string { - return createHash("sha256").update(String(raw || "").trim()).digest("hex").slice(0, 32); + return createHash("sha256") + .update(String(raw || "").trim()) + .digest("hex") + .slice(0, 32); } /** Pull a single cookie value from a Cookie header / paste blob. */ export function getAdobeCookieValue(cookieOrBlob: string, name: string): string { const raw = String(cookieOrBlob || ""); if (!raw || !name) return ""; - const re = new RegExp(`(?:^|[;\\s\\n\\r])${name.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}=([^;\\s\\n\\r]+)`, "i"); + const re = new RegExp( + `(?:^|[;\\s\\n\\r])${name.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}=([^;\\s\\n\\r]+)`, + "i" + ); const m = raw.match(re); if (!m?.[1]) return ""; let v = m[1].trim().replace(/^["']|["']$/g, ""); @@ -260,9 +281,7 @@ export function buildAdobeArpSessionIdFromCookies( if (!blob.trim()) return ""; const sid = - getAdobeCookieValue(blob, "ff_session_guid") || - getAdobeCookieValue(blob, "sid") || - ""; + getAdobeCookieValue(blob, "ff_session_guid") || getAdobeCookieValue(blob, "sid") || ""; const ark = getAdobeCookieValue(blob, "arkose") || ""; const ftr = normalizeAdobeForterToken(getAdobeCookieValue(blob, "forterToken")) || @@ -317,7 +336,11 @@ export function resolveAdobeArpSessionIdSmart( if (rebuilt && extracted) { const rebuiltFtr = (() => { try { - const j = JSON.parse(Buffer.from(rebuilt + "=".repeat((4 - (rebuilt.length % 4)) % 4), "base64").toString("utf8")) as { ftr?: string }; + const j = JSON.parse( + Buffer.from(rebuilt + "=".repeat((4 - (rebuilt.length % 4)) % 4), "base64").toString( + "utf8" + ) + ) as { ftr?: string }; return String(j.ftr || ""); } catch { return ""; @@ -325,7 +348,11 @@ export function resolveAdobeArpSessionIdSmart( })(); const extractedFtr = (() => { try { - const j = JSON.parse(Buffer.from(extracted + "=".repeat((4 - (extracted.length % 4)) % 4), "base64").toString("utf8")) as { ftr?: string }; + const j = JSON.parse( + Buffer.from(extracted + "=".repeat((4 - (extracted.length % 4)) % 4), "base64").toString( + "utf8" + ) + ) as { ftr?: string }; return String(j.ftr || ""); } catch { return ""; @@ -359,7 +386,10 @@ export function mergeAdobeCookieHeaders(base: string, updates: string): string { } catch { /* keep */ } - if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { value = value.slice(1, -1); } if (/[\r\n\0]/.test(value)) continue; @@ -372,7 +402,9 @@ export function mergeAdobeCookieHeaders(base: string, updates: string): string { } /** Serialize session back into a multi-line credential paste (JWT + Cookie). */ -export function serializeAdobeFireflyCredential(session: Pick): string { +export function serializeAdobeFireflyCredential( + session: Pick +): string { const lines: string[] = []; if (session.accessToken) lines.push(session.accessToken.trim()); if (session.arpSessionId) lines.push(session.arpSessionId.trim()); @@ -438,12 +470,8 @@ function collectCredentialBlobs( } /** - * Browser warm for Firefly risk session (Forter/Arkose refresh). - * - * - Default Chrome mode: **off-screen headed** (parked at -32000,-32000). Colligo rejects - * headless Forter tokens; true headless only with ADOBE_FIREFLY_CHROME_HEADLESS=1. - * - Proactive use stays opt-in (ADOBE_FIREFLY_BROWSER_REFRESH=1). - * - Mid-batch 408 recovery may call with force=true so we mint a new ARP without re-paste. + * Browser warm for Firefly risk session (Forter/Arkose + IMS JWT refresh). + * Uses the same persistent pure-CDP profile as interactive sign-in, including in pkg builds. * Never throws — returns null when unavailable. */ export async function refreshAdobeSessionViaBrowser( @@ -456,31 +484,33 @@ export async function refreshAdobeSessionViaBrowser( if (!adobeFireflyBrowserEnabled()) return null; try { - const { warmAdobeFireflyViaChrome } = await import("./adobeFireflyChromeRuntime.ts"); - const warmed = await warmAdobeFireflyViaChrome({ + const { refreshAdobeFireflyViaCdp } = await import("./adobeFireflyBrowserLogin.ts"); + const warmed = await refreshAdobeFireflyViaCdp({ cookie: session.cookie, accessToken: session.accessToken, log, - waitForLoginMs: 0, // never block on interactive login - // Default engine: warm the off-screen Chrome without requiring BROWSER_REFRESH=1. - allowWithoutEnvOptIn: true, - // Prove colligo accepts the ARP during recovery (in-page generate-async ping). - proveWithPing: opts?.proveWithPing ?? force, + timeoutMs: force ? 90_000 : 75_000, }); if (!warmed) return null; + const nextCookie = mergeAdobeCookieHeaders(session.cookie, warmed.cookie || ""); + const nextArp = + warmed.arpSessionId || + buildAdobeArpSessionIdFromCookies(nextCookie) || + extractAdobeArpSessionId(nextCookie); + if (!nextArp) return null; + const next: AdobeFireflySession = { ...session, accessToken: warmed.accessToken || session.accessToken, - // Prefer warmed jar (fresh forter) over stale paste when keys collide. - cookie: mergeAdobeCookieHeaders(session.cookie, warmed.cookie || ""), - arpSessionId: warmed.arpSessionId, - tokenExpiresAt: warmed.tokenExpiresAt || session.tokenExpiresAt, + cookie: nextCookie, + arpSessionId: nextArp, + tokenExpiresAt: estimateAdobeTokenExpiry(warmed.accessToken || session.accessToken), updatedAt: Date.now(), source: "browser", }; // When warm returns a fresher forter, prefer its cookie entirely for ARP rebuild pieces. - const warmFtr = extractAdobeForterTimestampMs(warmed.cookie || ""); + const warmFtr = extractAdobeForterTimestampMs(nextCookie); const baseFtr = extractAdobeForterTimestampMs(session.cookie || ""); if (warmFtr > baseFtr && warmed.cookie) { next.cookie = mergeAdobeCookieHeaders(session.cookie, warmed.cookie); @@ -490,13 +520,13 @@ export async function refreshAdobeSessionViaBrowser( clearAdobeFireflyWorkingArp(session.fingerprint); log?.info?.( "ADOBE-FIREFLY", - `off-screen Chrome warm refreshed ARP (len=${next.arpSessionId.length}, force=${force}, forterTs=${warmFtr || 0})` + `durable CDP warm refreshed session (arpLen=${next.arpSessionId.length}, force=${force}, forterTs=${warmFtr || 0})` ); return next; } catch (err) { log?.warn?.( "ADOBE-FIREFLY", - `browser ARP refresh failed: ${err instanceof Error ? err.message : String(err)}` + `browser CDP session refresh failed: ${err instanceof Error ? err.message : String(err)}` ); return null; } @@ -504,7 +534,7 @@ export async function refreshAdobeSessionViaBrowser( /** * Resolve a durable Firefly session from stored credentials. - * Caches in memory + DATA_DIR; rebuilds ARP from cookies; optionally warms via Playwright. + * Caches in memory + DATA_DIR; rebuilds ARP from cookies; optionally warms via durable CDP. */ export async function ensureAdobeFireflySession( opts: AdobeFireflySessionResolveOpts @@ -542,15 +572,22 @@ export async function ensureAdobeFireflySession( break; } } - // Cookie-only paste: use short-lived memory cache JWT only (not a stale disk token alone) + // A browser-refreshed disk token must survive process restarts. Prefer it when the pasted + // token is absent or near expiry; the fingerprint still binds it to these credentials. + const pastedExpiresAt = accessToken ? estimateAdobeTokenExpiry(accessToken) : 0; + const cachedExpiresAt = cached?.accessToken + ? cached.tokenExpiresAt > 0 + ? cached.tokenExpiresAt + : estimateAdobeTokenExpiry(cached.accessToken) + : 0; if ( - !accessToken && cached?.accessToken && isAdobeUserAccessToken(cached.accessToken) && - sessionCache.has(fingerprint) && - Date.now() - cached.updatedAt < 30 * 60_000 + cachedExpiresAt - Date.now() >= JWT_REFRESH_SKEW_MS && + (!accessToken || pastedExpiresAt - Date.now() < JWT_REFRESH_SKEW_MS) ) { accessToken = cached.accessToken; + pasteHadUserJwt = false; } // Cookie blob @@ -642,15 +679,16 @@ export async function ensureAdobeFireflySession( // - no AdobeID user JWT yet (profile may hold one — cookie/JWT-free path), or // - stale Forter risk session and no recently-accepted (sticky 2xx) ARP to reuse. const jwtIsUser = isAdobeUserAccessToken(session.accessToken); + const jwtNeedsBrowserRefresh = + !jwtIsUser || session.tokenExpiresAt - Date.now() < JWT_REFRESH_SKEW_MS; const forterAgeMs = getAdobeForterAgeMs(session.cookie); const riskStale = !workingFresh && forterAgeMs > FORTER_PROACTIVE_WARM_MS; const shouldWarm = adobeFireflyBrowserEnabled() && opts.allowBrowserRefresh !== false && - (opts.forceRefresh || opts.rotateArp || !jwtIsUser || riskStale); - // Need something to warm from: a cookie to seed, a signed-in profile (no user JWT yet), - // or an explicit refresh request. - const canWarm = Boolean(session.cookie) || !jwtIsUser || Boolean(opts.forceRefresh); + (opts.forceRefresh || opts.rotateArp || jwtNeedsBrowserRefresh || riskStale); + // A persistent signed-in browser profile can refresh even when the stored cookie is empty. + const canWarm = true; if (shouldWarm && canWarm) { const key = fingerprint; let inflight = browserRefreshInFlight.get(key); @@ -668,7 +706,7 @@ export async function ensureAdobeFireflySession( session = { ...warmed, fingerprint }; opts.log?.info?.( "ADOBE-FIREFLY", - `off-screen Chrome session warm applied (reason=${opts.forceRefresh ? "force" : opts.rotateArp ? "rotate" : !jwtIsUser ? "no-user-jwt" : "stale-forter"})` + `durable CDP session warm applied (reason=${opts.forceRefresh ? "force" : opts.rotateArp ? "rotate" : jwtNeedsBrowserRefresh ? "jwt-expiry" : "stale-forter"})` ); } } @@ -690,12 +728,20 @@ export async function ensureAdobeFireflySession( if (!isAdobeUserAccessToken(session.accessToken)) { throw new AdobeFireflyError( "Adobe Firefly is not signed in. On Providers → Adobe Firefly → Add Account (OAuth) choose " + - "\"Sign in with browser\" (fresh login window) or \"Paste JWT / Cookie\". After browser sign-in " + + '"Sign in with browser" (fresh login window) or "Paste JWT / Cookie". After browser sign-in ' + "the app stores JWT+Cookie and keeps the risk session fresh automatically.", 401, "not_signed_in" ); } + if (session.tokenExpiresAt <= Date.now() + 30_000) { + throw new AdobeFireflyError( + "Adobe Firefly browser session expired and could not renew automatically. Re-open the " + + "Adobe Firefly account and sign in once so the durable browser profile can renew future JWTs.", + 401, + "session_expired" + ); + } sessionCache.set(fingerprint, session); saveDiskSession(session); @@ -717,8 +763,16 @@ export async function rotateAdobeFireflySessionOnError( log?: AdobeFireflySessionResolveOpts["log"]; /** Attempt index (1-based) for backoff policy. */ attempt?: number; + /** 401/403: bypass quiet ARP reuse and refresh JWT + cookies immediately. */ + authFailure?: boolean; } ): Promise { + if (session.tokenExpiresAt <= 0) { + session = { + ...session, + tokenExpiresAt: estimateAdobeTokenExpiry(session.accessToken), + }; + } const prevArp = session.arpSessionId; const attempt = opts?.attempt ?? 1; const forterTs = extractAdobeForterTimestampMs(session.cookie); @@ -729,8 +783,12 @@ export async function rotateAdobeFireflySessionOnError( // Attempt 1–2 when forter is not known-stale: keep same ARP (colligo short load / rate limit). // Hours-old forter → skip quiet reuse and warm Chrome immediately (else all 5 attempts 408). - if (attempt <= 2 && !forterKnownStale) { - const same: AdobeFireflySession = { ...session, updatedAt: Date.now(), source: "cache" }; + if (attempt <= 2 && !forterKnownStale && !opts?.authFailure) { + const same: AdobeFireflySession = { + ...session, + updatedAt: Date.now(), + source: "cache", + }; sessionCache.set(session.fingerprint, same); saveDiskSession(same); opts?.log?.info?.( @@ -741,16 +799,16 @@ export async function rotateAdobeFireflySessionOnError( } // Known-stale forter or attempt 3+: cookie rebuild is a no-op. Off-screen headed Chrome mints a - // fresh Forter/ARP (headless is rejected by colligo — see adobeFireflyChromeRuntime). + // fresh Forter/ARP (headless browsers are rejected by colligo, so CDP uses off-screen headed mode). clearAdobeFireflyWorkingArp(session.fingerprint); noteAdobeFireflySubmitFailure(); const tryBrowser = opts?.tryBrowser !== false && process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "0"; - if (tryBrowser && session.cookie) { + if (tryBrowser) { opts?.log?.info?.( "ADOBE-FIREFLY", - `408 recovery: off-screen Chrome warm (attempt=${attempt}, forterKnownStale=${forterKnownStale}, forterAgeMs=${forterAgeMs ?? "unknown"})` + `${opts?.authFailure ? "auth" : "408"} recovery: durable CDP warm (attempt=${attempt}, forterKnownStale=${forterKnownStale}, forterAgeMs=${forterAgeMs ?? "unknown"})` ); const warmed = await refreshAdobeSessionViaBrowser(session, opts?.log, { force: true, @@ -762,13 +820,15 @@ export async function rotateAdobeFireflySessionOnError( saveDiskSession(next); opts?.log?.info?.( "ADOBE-FIREFLY", - `408 recovery: Chrome warm done (arp changed=${warmed.arpSessionId !== prevArp}, forterTs=${extractAdobeForterTimestampMs(warmed.cookie)})` + `${opts?.authFailure ? "auth" : "408"} recovery: CDP warm done (arp changed=${warmed.arpSessionId !== prevArp}, forterTs=${extractAdobeForterTimestampMs(warmed.cookie)})` ); return next; } } - const rebuilt = resolveAdobeArpSessionIdSmart(session.cookie, { rotate: true }); + const rebuilt = resolveAdobeArpSessionIdSmart(session.cookie, { + rotate: true, + }); const next: AdobeFireflySession = { ...session, arpSessionId: rebuilt && rebuilt !== prevArp ? rebuilt : session.arpSessionId, diff --git a/tests/unit/adobe-firefly-browser-login.test.ts b/tests/unit/adobe-firefly-browser-login.test.ts index 28a93750c8..6e778a4c0f 100644 --- a/tests/unit/adobe-firefly-browser-login.test.ts +++ b/tests/unit/adobe-firefly-browser-login.test.ts @@ -35,10 +35,16 @@ test("buildAdobeFireflyCookieHeader keeps only wanted pairs", () => { { name: "unrelated", value: "x" }, { name: "sherlockToken", value: "s1" }, { name: "forterToken", value: "f1" }, + { name: "arkose", value: "a1" }, { name: "bad", value: "a;b" }, { name: "ff_session_guid", value: "g1" }, + { name: "bfp", value: "b1" }, + { name: "fpjs", value: "j1" }, ]); - assert.equal(header, "sherlockToken=s1; forterToken=f1; ff_session_guid=g1"); + assert.equal( + header, + "sherlockToken=s1; forterToken=f1; arkose=a1; ff_session_guid=g1; bfp=b1; fpjs=j1" + ); assert.equal(buildAdobeFireflyCookieHeader([]), ""); }); diff --git a/tests/unit/adobe-firefly.test.ts b/tests/unit/adobe-firefly.test.ts index f29e9a1422..d4deaa70a7 100644 --- a/tests/unit/adobe-firefly.test.ts +++ b/tests/unit/adobe-firefly.test.ts @@ -88,7 +88,10 @@ test("getExecutor(adobe-firefly) rejects chat completions", async () => { assert.ok(executor); const result = await executor.execute({ model: "adobe-firefly/nano-banana-pro", - body: { model: "adobe-firefly/nano-banana-pro", messages: [{ role: "user", content: "hi" }] }, + body: { + model: "adobe-firefly/nano-banana-pro", + messages: [{ role: "user", content: "hi" }], + }, stream: false, credentials: { apiKey: "tok" }, }); @@ -208,10 +211,7 @@ test("buildAdobeImagePayload attaches referenceBlobs like live adobe_atach_image { id: "2a4f1025-e0dc-4671-a11a-7dfd3c07bd94", usage: "general" }, { id: "84c11d1a-e798-4300-a63e-c06504ca2068", usage: "general" }, ]); - assert.equal( - (nano.generationMetadata as Record).module, - "text2image" - ); + assert.equal((nano.generationMetadata as Record).module, "text2image"); const gpt = buildAdobeImagePayload({ prompt: "edit me", @@ -260,7 +260,9 @@ test("parseAdobeImageSourceBytes + parseAdobeStorageUploadResponse", () => { }); test("buildAdobeUploadHeaders uses image content-type not json", () => { - const h = buildAdobeUploadHeaders("tok", "image/png", { arpSessionId: "arp" }); + const h = buildAdobeUploadHeaders("tok", "image/png", { + arpSessionId: "arp", + }); assert.equal(h["content-type"], "image/png"); assert.equal(h.Authorization, "Bearer tok"); assert.equal(h["x-api-key"], "clio-playground-web"); @@ -280,10 +282,10 @@ test("resolveAdobeSourceImageIds uploads data URLs then returns blob ids", async const headers = init?.headers as Record; assert.match(String(headers["content-type"] || headers["Content-Type"] || ""), /image\//); assert.ok(init?.body); - return new Response( - JSON.stringify({ images: [{ id: `blob-${uploadCalls}` }] }), - { status: 200, headers: { "content-type": "application/json" } } - ); + return new Response(JSON.stringify({ images: [{ id: `blob-${uploadCalls}` }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); } throw new Error(`unexpected fetch ${u}`); }; @@ -328,12 +330,16 @@ test("buildAdobeVideoPayload produces sora and veo shapes", () => { }); test("extractAdobeResultLink prefers x-override-status-link then links.result", () => { - const headers = new Headers({ "x-override-status-link": "https://poll.example/job/1" }); + const headers = new Headers({ + "x-override-status-link": "https://poll.example/job/1", + }); assert.equal(extractAdobeResultLink(headers, {}), "https://poll.example/job/1"); const headers2 = new Headers(); assert.equal( - extractAdobeResultLink(headers2, { links: { result: { href: "https://poll.example/job/2" } } }), + extractAdobeResultLink(headers2, { + links: { result: { href: "https://poll.example/job/2" } }, + }), "https://poll.example/job/2" ); }); @@ -387,8 +393,7 @@ test("buildAdobeSubmitNonce is sha256(user_id + prompt[:256])", async () => { type: "access_token", client_id: "clio-playground-web", }) - ) - .toString("base64url"); + ).toString("base64url"); const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"); const token = `${header}.${payload}.${"x".repeat(40)}`; // Pad token length for looksLikeAdobeJwt (>=80) @@ -449,8 +454,7 @@ test("buildAdobeSubmitNonce is sha256(user_id + prompt[:256])", async () => { }); test("normalizeAdobePollUrl rewrites firefly-epo jobs/result to BKS", () => { - const raw = - "https://firefly-epo855232.adobe.io/jobs/result/4ae9fd2a-0864-46dd-9834-cfc16e91faa6"; + const raw = "https://firefly-epo855232.adobe.io/jobs/result/4ae9fd2a-0864-46dd-9834-cfc16e91faa6"; const out = normalizeAdobePollUrl(raw); assert.match(out, /^https:\/\/bks-epo8552\.adobe\.io\/v2\/jobs\/result\/4ae9fd2a/); assert.match(out, /host=firefly-epo855232\.adobe\.io/); @@ -464,7 +468,9 @@ test("parseAdobeCreditsBalance maps total + free/plan buckets", () => { }, credits: { firefly_free_credit: { quota: { total: 10, used: 0, available: 10 } }, - firefly_plan_credit: { quota: { total: 10000, used: 10, available: 9990 } }, + firefly_plan_credit: { + quota: { total: 10000, used: 10, available: 9990 }, + }, }, }); assert.equal(bal.total, 10010); @@ -532,9 +538,9 @@ test("fallback catalog has image and video entries from get_models capture", () test("extractAdobeAccountIdFromToken reads user_id claim", () => { // {"user_id":"0EB@AdobeID"} base64url - const payload = Buffer.from(JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token" })).toString( - "base64url" - ); + const payload = Buffer.from( + JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token" }) + ).toString("base64url"); const jwt = `eyJhbGciOiJub25lIn0.${payload}.sig`; assert.equal(extractAdobeAccountIdFromToken(jwt), "0EB@AdobeID"); }); @@ -571,7 +577,11 @@ function userImsJwt(userId = "0EB@AdobeID"): string { return ( `eyJhbGciOiJSUzI1NiJ9.` + Buffer.from( - JSON.stringify({ user_id: userId, type: "access_token", client_id: "clio-playground-web" }) + JSON.stringify({ + user_id: userId, + type: "access_token", + client_id: "clio-playground-web", + }) ).toString("base64url") + `.` + "sig".padEnd(40, "x") @@ -652,7 +662,10 @@ test("handleAdobeFireflyImageGeneration uploads refs and submits referenceBlobs" assert.equal(result.success, true); assert.equal(uploadCalls, 1); assert.ok(sawGenerateBody); - const refs = sawGenerateBody!.referenceBlobs as Array<{ id: string; usage: string }>; + const refs = sawGenerateBody!.referenceBlobs as Array<{ + id: string; + usage: string; + }>; assert.deepEqual(refs, [{ id: "ref-blob-1", usage: "general" }]); }); @@ -719,13 +732,19 @@ test("guest JWT without AdobeID is detected", () => { const emptyPayload = Buffer.from("{}").toString("base64url"); const guestJwt = `eyJhbGciOiJub25lIn0.${emptyPayload}.sig`; // Pad to lookLikeAdobeJwt length if needed - const longGuest = `eyJhbGciOiJSUzI1NiJ9.${Buffer.from(JSON.stringify({ client_id: "clio-playground-web" })).toString("base64url")}.` + "x".repeat(40); + const longGuest = + `eyJhbGciOiJSUzI1NiJ9.${Buffer.from(JSON.stringify({ client_id: "clio-playground-web" })).toString("base64url")}.` + + "x".repeat(40); assert.equal(isAdobeGuestAccessToken(longGuest), true); const userJwt = `eyJhbGciOiJSUzI1NiJ9.` + - Buffer.from(JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token", client_id: "clio-playground-web" })).toString( - "base64url" - ) + + Buffer.from( + JSON.stringify({ + user_id: "0EB@AdobeID", + type: "access_token", + client_id: "clio-playground-web", + }) + ).toString("base64url") + `.` + "y".repeat(40); assert.equal(isAdobeGuestAccessToken(userJwt), false); @@ -802,9 +821,8 @@ test("extractAdobeArpSessionId recovers JWT+ARP joined by space (PasswordBox man }); test("extractAdobeArpSessionId does not pick aux_sid over sherlockToken", async () => { - const { extractAdobeArpSessionId, isValidAdobeArpSessionId } = await import( - "../../open-sse/services/adobeFireflyClient.ts" - ); + const { extractAdobeArpSessionId, isValidAdobeArpSessionId } = + await import("../../open-sse/services/adobeFireflyClient.ts"); const { ADOBE_FIREFLY_FTR_MAGIC } = await import("../../open-sse/services/adobeFireflyClient.ts"); const realArp = Buffer.from( JSON.stringify({ @@ -879,7 +897,13 @@ test("rebuild ARP from cookie components (forter+arkose+sid)", async () => { }); test("isAdobeTransientSubmitError detects 408 system under load", () => { - assert.equal(isAdobeTransientSubmitError(408, '{"error_code":"timeout_error","message":"system under load"}'), true); + assert.equal( + isAdobeTransientSubmitError( + 408, + '{"error_code":"timeout_error","message":"system under load"}' + ), + true + ); assert.equal(isAdobeTransientSubmitError(429, "rate"), true); assert.equal(isAdobeTransientSubmitError(400, "bad request"), false); assert.ok(generateAdobeNonce().length === 64); @@ -918,9 +942,8 @@ test("resolveAdobeImageModel maps gpt-image-2 alias", async () => { }); test("image submit retries on 408 then succeeds", async () => { - const { __resetAdobeFireflySessionCacheForTests } = await import( - "../../open-sse/services/adobeFireflySession.ts" - ); + const { __resetAdobeFireflySessionCacheForTests } = + await import("../../open-sse/services/adobeFireflySession.ts"); __resetAdobeFireflySessionCacheForTests(); let submits = 0; @@ -930,13 +953,12 @@ test("image submit retries on 408 then succeeds", async () => { if (u.includes("generate-async")) { submits += 1; if (submits < 3) { - return jsonResponse(408, { error_code: "timeout_error", message: "system under load" }); + return jsonResponse(408, { + error_code: "timeout_error", + message: "system under load", + }); } - return jsonResponse( - 200, - { links: { result: { href: "https://poll.example/job/r1" } } }, - {} - ); + return jsonResponse(200, { links: { result: { href: "https://poll.example/job/r1" } } }, {}); } if (u.includes("poll.example")) { return jsonResponse(200, { @@ -1005,8 +1027,7 @@ test("rotateAdobeFireflySessionOnError: attempt1-2 reuse sticky; attempt3 keeps __resetAdobeFireflySessionCacheForTests(); const ftr = `aa_${Date.now()}${ADOBE_FIREFLY_FTR_MAGIC}_x=-1-v2_tt`; - const cookie = - `ff_session_guid=sid-1; arkose=ark-1; forterToken=${encodeURIComponent(ftr)}`; + const cookie = `ff_session_guid=sid-1; arkose=ark-1; forterToken=${encodeURIComponent(ftr)}`; const rebuilt = buildAdobeArpSessionIdFromCookies(cookie); assert.ok(rebuilt); @@ -1039,19 +1060,33 @@ test("rotateAdobeFireflySessionOnError: attempt1-2 reuse sticky; attempt3 keeps ); // attempt 3 without browser: cookie rebuild (identical forter → same ARP) assert.ok(a3.arpSessionId, "attempt 3 still yields an ARP"); - assert.equal(a3.arpSessionId, rebuilt, "identical cookie rebuild keeps ARP (no synthetic thrash)"); + assert.equal( + a3.arpSessionId, + rebuilt, + "identical cookie rebuild keeps ARP (no synthetic thrash)" + ); + + const auth = await rotateAdobeFireflySessionOnError( + { ...base, arpSessionId: rebuilt!, tokenExpiresAt: 0 }, + { attempt: 1, authFailure: true, tryBrowser: false } + ); + assert.equal(auth.source, "rebuild", "auth failures must bypass quiet sticky reuse"); + assert.ok(auth.tokenExpiresAt > Date.now(), "retry must repair a missing token expiry"); }); test("adobeFireflyGenerateImage cookie path exchanges IMS token first", async () => { - const { __resetAdobeFireflySessionCacheForTests } = await import( - "../../open-sse/services/adobeFireflySession.ts" - ); + const { __resetAdobeFireflySessionCacheForTests } = + await import("../../open-sse/services/adobeFireflySession.ts"); __resetAdobeFireflySessionCacheForTests(); const userTok = `eyJhbGciOiJSUzI1NiJ9.` + Buffer.from( - JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token", client_id: "clio-playground-web" }) + JSON.stringify({ + user_id: "0EB@AdobeID", + type: "access_token", + client_id: "clio-playground-web", + }) ).toString("base64url") + `.` + "s".repeat(40); @@ -1061,7 +1096,10 @@ test("adobeFireflyGenerateImage cookie path exchanges IMS token first", async () if (String(url).includes("ims/check")) { assert.equal(init?.method, "POST"); // Authenticated exchange (guest_allowed=false) - return jsonResponse(200, { access_token: userTok, account_type: "type1" }); + return jsonResponse(200, { + access_token: userTok, + account_type: "type1", + }); } if (String(url).includes("generate-async")) { const auth = @@ -1073,11 +1111,7 @@ test("adobeFireflyGenerateImage cookie path exchanges IMS token first", async () ? (init.headers as Record).Authorization : auth; assert.equal(headerAuth, `Bearer ${userTok}`); - return jsonResponse( - 200, - {}, - { "x-override-status-link": "https://poll.example/job/c1" } - ); + return jsonResponse(200, {}, { "x-override-status-link": "https://poll.example/job/c1" }); } if (String(url).includes("poll.example")) { return jsonResponse(200, {