From 57fb90d734b47bd097c8d8447f6e69a5fc0aa775 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 10:07:17 -0300 Subject: [PATCH] maint: follow-up cherry-pick fix-in-place #9549 (conflict-resolved fallback) (#9881) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(adobe-firefly): open browser sign-in and resolve provider slug in /login POST /api/providers/[id]/login passed the connection DB id to inAppLoginService.startLogin, but that service looks up the provider by slug in TOKEN_EXTRACTION_CONFIGS. The lookup always missed and returned "No extraction config" without launching a browser — so the VibeProxy "Sign in" button for Adobe Firefly (and every other web-cookie provider) never opened a browser. Adobe Firefly additionally had no extraction config because its IMS JWT is never in cookies/localStorage — it only rides on the Authorization: Bearer header of firefly-3p.ff.adobe.io XHRs. - Resolve the provider slug from the connection row and pass the slug (not the DB id) to inAppLoginService.startLogin. - Add open-sse/services/adobeFireflyBrowserLogin.ts: a Playwright service that launches a visible browser at firefly.adobe.com and intercepts firefly-3p requests to capture the IMS JWT + sherlockToken cookie. Wire it into the /login route for the adobe-firefly slug. - Fix latent bug: updateProviderConnection reads camelCase keys (apiKey, providerSpecificData), so the previous snake_case call never persisted extracted credentials. * fix(adobe-firefly): open browser sign-in and resolve provider slug in /login POST /api/providers/[id]/login passed the connection DB id to inAppLoginService.startLogin, but TOKEN_EXTRACTION_CONFIGS is keyed by provider slug — so browser login never launched for web-cookie providers. Adobe Firefly also cannot use cookie extraction: the IMS JWT only appears on Authorization headers to firefly-3p.ff.adobe.io. Add a dedicated Playwright interceptor and persist credentials with camelCase keys that updateProviderConnection actually reads. * fix(adobe-firefly): use system Chrome/Edge CDP for browser sign-in Playwright is not available inside the pkg-packaged VibeProxyServices.exe, so import('playwright') always failed with 'Playwright not installed' and never opened a window. Launch Chrome/Edge with --remote-debugging-port and capture the firefly-3p Authorization Bearer via pure CDP WebSocket instead. * fix(adobe-firefly): live x-arp-session-id / Arkose wire (stop 408 under load) Browser generate-async requires x-arp-session-id as base64({sid,ark,ftr}) with a real Arkose blob (sherlockToken). JWT alone frequently returns colligo HTTP 408 system under load while credits still work. - Match live ftr magic __UDF43-m4_31ck + Arkose pk in synthetic ARP fallback - Ranked extract of sherlockToken / x-arp from Cookie, HAR, fetch() paste, and space-joined JWT+ARP (PasswordBox newline collapse) - Reuse one ARP for storage upload + generate-async - Clearer 408 errors when browser ARP is missing vs stale - Unit suite 42/42 * fix(adobe-firefly): durable session ARP rebuild and aux_sid false-positive Rebuild x-arp-session-id from forterToken/arkose/ff_session_guid instead of ranking long Cookie pairs (e.g. aux_sid=…) as opaque ARP, which caused colligo HTTP 408. Cache IMS JWT + cookie sessions, rotate ARP on 408 retries, and keep Playwright warm-up opt-in only (headless Forter is rejected). Also expand synthetic ARP shape with bfp/fpjs to match live successful captures. * fix(adobe-firefly): durable session, off-screen Chrome recovery, browser sign-in Rebuild x-arp-session-id from Cookie pieces (sid/ark/forter) so aux_sid is never sent as ARP. Sticky ARP + submit spacing reduce mid-batch colligo 408 thrash. Add optional managed Chrome warm (off-screen headed by default; Forter rejects headless) and POST /api/providers/{id}/login browser sign-in that returns JWT+Cookie after a fresh SSO. Visible sign-in resets off-screen window placement and clears prior Adobe session when adding another account. * fix(adobe-firefly): renew sessions through durable CDP * fix(adobe-firefly): isolate browser sessions per account * fix(adobe-firefly): make account login fresh and deterministic * chore(adobe-firefly): remove obsolete browser fallback * docs(adobe-firefly): document renewal controls * 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) --------- Co-authored-by: artickc --- .env.example | 13 + config/quality/file-size-baseline.json | 6 +- docs/reference/ENVIRONMENT.md | 6 + .../imageGeneration/providers/adobeFirefly.ts | 89 +- .../videoGeneration/adobeFireflyHandler.ts | 52 +- open-sse/services/adobeFireflyBrowserLogin.ts | 1181 ++++++++++-- .../services/adobeFireflyChromeRuntime.ts | 1200 ++++++++++++ open-sse/services/adobeFireflyClient.ts | 1659 ++++++++++++----- open-sse/services/adobeFireflySession.ts | 1002 ++++++++++ src/app/api/providers/[id]/login/route.ts | 205 +- .../unit/adobe-firefly-browser-login.test.ts | 160 +- tests/unit/adobe-firefly.test.ts | 499 +++-- 12 files changed, 5143 insertions(+), 929 deletions(-) create mode 100644 open-sse/services/adobeFireflyChromeRuntime.ts create mode 100644 open-sse/services/adobeFireflySession.ts diff --git a/.env.example b/.env.example index aa9f4d443d..3aecf2f17e 100644 --- a/.env.example +++ b/.env.example @@ -1522,6 +1522,19 @@ APP_LOG_TO_FILE=true # NANOBANANA_POLL_TIMEOUT_MS=120000 # Max wait for job completion (default: 120s) # NANOBANANA_POLL_INTERVAL_MS=2500 # Poll frequency (default: 2.5s) +# ── Adobe Firefly (Image / Video Generation) ── +# Optional absolute path to a system Chrome or Edge executable used for interactive sign-in +# and off-screen risk-session renewal. Auto-detected when unset. +# OMNIROUTE_LOGIN_BROWSER_PATH= +# Browser renewal and durable session cache are enabled by default; set either to 0 to opt out. +# ADOBE_FIREFLY_BROWSER_REFRESH=1 +# ADOBE_FIREFLY_SESSION_DISK=1 +# Minimum gap between generate submissions and extra gap after every third success (ms). +# ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS=12000 +# ADOBE_FIREFLY_BATCH_EXTRA_GAP_MS=15000 +# Base backoff after a transient 408 response (ms); five attempts maximum. +# ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS=8000 + # ── Microsoft Designer Web (Image Generation) ── # Polling config for the microsoft-designer-web submit-then-poll image job. # Used by: open-sse/handlers/imageGeneration/providers/designerWeb.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index bc11d7fb1b..8cf2354c94 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -426,9 +426,6 @@ "_rebaseline_2026_07_28_8863_firefly_detail_level": "PR #8863 (fix/adobe-firefly-gpt-detail-level-max) own growth: adobeFireflyClient.ts 2317->2322 (+5 = gpt-image detailLevel defaulting to maximal at the existing payload-build site). Covered by tests/unit/adobe-firefly.test.ts.", "_rebaseline_2026_07_29_8281_home_quickstart_prefetch": "Release v3.8.49 base-red fix (no PR — captain sweep): src/app/(dashboard)/dashboard/HomePageClient.tsx 1377->1381 (+4). #8292 added prefetch={false} to the sidebar but left /home's five quick-start Links prefetching, so first paint still fired 12 speculative RSC requests — caught by navigation.spec.ts only after the e2e helper bug (APP_ROUTE_PATTERN missing /home) was repaired in the same cycle. Growth is the five prefetch attributes; it was offset first by extracting the repeated className literals (INLINE_LINK x4, DOCS_LINK x1), which collapsed five wrapped blocks back to one line each — a naive fix measured 1391. Guard: tests/unit/sidebar-prefetch-policy-8281.test.ts.", "_rebaseline_2026_08_02_v3850_agentrouter_responses": "Release v3.8.50 AgentRouter/Codex compatibility reconciliation. open-sse/executors/base.ts 1562->1578: #9190 wires AgentRouter's selected Claude/OpenAI/Responses protocol through the existing executor URL, auth, identity-header and fingerprint chokepoints; the reusable alternate resolver remains outside base.ts. open-sse/utils/stream.ts 2887->2889: #9213 evaluates Responses ID and usage normalization independently so response.completed always receives finite usage.total_tokens instead of short-circuiting after an ID rewrite. tests/unit/chatcore-translation-paths.test.ts 2769->2776: #9191 updates the existing Claude-Code bridge assertions for the dynamic AgentRouter wire image. PR #9224 offsets its own chatCore growth by extracting the AgentRouter protocol decisions into chatCore/agentRouterProtocol.ts, leaving chatCore below its frozen ceiling. Covered by agentrouter executor/chatCore protocol tests, chatcore translation-path tests, and responses-commentary-passthrough tests.", - "_rebaseline_2026_08_08_v3850_base_drift_batch_9757": "Base drift on release/v3.8.50, not own growth: the 08-06..08-08 merge batches grew 12 already-frozen (or newly-landed) files without carrying their rebaselines — the dedicated rebaseline PR #9616 was closed as 'superseded' but its file-size entries never actually reached the base, and later merges (#8894 combos page, #9539 EditConnectionModal, #8895 models route, #9294/#9293 catalog, #9541 db/core, #8970 tokenHealthCheck, #8925 mcp schemas+server, #8890 accountFallback, #9467 chat.ts, #8931 openai-to-kiro, ProxyRegistryManager) kept growing them. All 12 values re-measured on THIS branch's tree (= pure tip + this PR's 1-line chat.ts fix, which adds zero lines). This PR's own source changes (chat.ts identifier restore, stream.ts format carve-out) do not grow any frozen file past these values.", - "_rebaseline_2026_08_08_migration_135_collision": "fix(db): resolve migration version 135 numbering collision — #9449's 135_connection_runtime_state.sql and #8908's 135_migrate_model_capability_max_token.sql both claimed version 135 (#9449 branched before #8908 merged and never got renumbered before landing on release/v3.8.50), which threw 'Migration version collision detected' the moment ANY code touched the database — a fresh install/deploy from this tip cannot even boot. Renumbered the later-landing file to 140 (next free slot) and added the matching isSchemaAlreadyApplied('140') retroactive guard, matching the established pattern already used for the prior 135/136 -> 137/138 renumber in the same file. Own growth: src/lib/db/migrationRunner.ts 1084->1094 (+10, the new case block) — irreducible, matches the existing per-case guard pattern exactly. Covered by tests/unit/migration-135-numbering-collision.test.ts (2/2), confirmed failing (reproducing the exact live crash) against the pre-fix colliding filenames, passing after.", - "_rebaseline_2026_08_02_9259_rolling_rpm": "PR #9259 (issue #8733) own growth: open-sse/services/rateLimitManager.ts baseline 1060->1167 (+107; final source 1153). The existing withRateLimit chokepoint now composes process-local rolling RPM leases with Bottleneck admission, releases pre-dispatch leases on queue timeout/abort/connection disable, preserves caller abort reasons, and wires 429/header state into the extracted rollingRpmGate.ts. The remaining growth is irreducible lifecycle wiring at the dispatch boundary plus the real watchdog test hooks needed to verify queued-wedge recovery; moving it further would obscure lease ownership and Bottleneck cleanup. Covered by the focused rate-limit manager/sliding-window suite (33/33); distributed multi-instance coordination remains explicitly out of scope.", "_rebaseline_2026_07_25_dario_upstream_proxy_selector": "PR #8523 (Dario embedded service): upstream-proxy mode selector replaces the binary CLIProxyAPI toggle with Native/CLIProxyAPI/Dario/Fallback + a fallback-backend picker. ProviderDetailPageClient.tsx 798->804 (+6, new hook fields threaded through to ConnectionsListPanel), ConnectionRow.tsx 942->958 (+16, the mode replacing a single pill button), useProviderConnections.ts 954->986 (+32, upstreamProxyMode/upstreamProxyFallbackBackend state + handleSetUpstreamProxyMode, handleToggleCliproxyapiMode kept as a thin backward-compat wrapper for the existing hook-shape test). All additive UI/state for the new modes — no unrelated refactor.", "_rebaseline_2026_08_02_9242_token_health_transient": "PR #9242 (fix/refresh-circuit-transient): src/lib/tokenHealthCheck.ts 1021 (new file, above cap 1000). The file consolidates token-refresh health checking logic that was previously scattered across auth.ts and tokenRefresh.ts. Cohesive single-responsibility module for refresh circuit state management; not extractable without splitting the refresh state machine. Covered by tests/unit/tokenHealthCheck-transient.test.ts.", "_rebaseline_2026_07_28_8870_firefly_ref_cap_timeout": "PR #8870 (fix/adobe-firefly-gpt-ref-cap-timeout) own growth: adobeFireflyClient.ts 2322->2385 (+63 = gpt-image subject-ref hard cap at 2 + adaptive poll timeout budget (base 300s + 60s/ref, max 600s) + defensive .slice on referenceBlobs for gpt/nano/generic families). Fixes live 504s on multi-screenshot listing jobs (Featured Promo / Box Art) where 3–4+ subject refs stall colligo until the old 180s poll budget expires. Helpers adobeFireflyMaxImageRefs/adobeFireflyImageTimeoutMs live next to the existing payload/poll chokepoint (not extractable without splitting the wire recipe mid-PR). Covered by tests/unit/adobe-firefly.test.ts (ref-cap + timeout cases). Structural shrink tracked in #3501.", @@ -568,5 +565,6 @@ "open-sse/utils/sseHeartbeat.ts": "142", "_rebaseline_2026_08_04_9305_sse_comments": "#9305 fix: broadened sseCommentsEnabled()", "_rebaseline_2026_08_09_v3850_release_close": "Release v3.8.50 close reconciliation on e0ce95c592: src/sse/handlers/chat.ts 1904->1918 is the irreducible request-pipeline wiring from #9759 that invokes the Modality Bridge guardrail without moving its implementation into the handler; covered by the 17 Vision Bridge canaries plus the PR-1 focused suite. open-sse/translator/response/openai-responses.ts 1204->1215 is #9168's Responses tool-call argument delta buffering/normalization at the existing translator state-machine chokepoint; covered by its dedicated translator regression tests. Both values are measured by check:file-size (split-newline semantics), and the gate remains frozen at the new exact sizes.", - "_rebaseline_2026_08_08_toolcall_message_index_collision": "fix(responses-api): tool call after a text message collided on the same output_index. own growth: open-sse/translator/response/openai-responses.ts 1204->1224 (+20, extracted toolCallOutputIndexBase() shared helper so emitToolCall/closeToolCall can no longer compute a tool call's output_index independently and collide with a text message emitted in the same turn). Live incident (2026-08-08, OpenClaw agent): a client that tracks response items by output_index saw the tool call's added/delta/done events land on an index it had already marked complete (the just-closed text message), and silently dropped them — the agent spoke its preamble and never executed the tool call, even though OmniRoute's own recorded responseBody had a complete, valid tool_calls entry. Covered by the new regression test in tests/unit/translator-resp-openai-responses.test.ts reproducing the exact live scenario." + "_rebaseline_2026_08_08_toolcall_message_index_collision": "fix(responses-api): tool call after a text message collided on the same output_index. own growth: open-sse/translator/response/openai-responses.ts 1204->1224 (+20, extracted toolCallOutputIndexBase() shared helper so emitToolCall/closeToolCall can no longer compute a tool call's output_index independently and collide with a text message emitted in the same turn). Live incident (2026-08-08, OpenClaw agent): a client that tracks response items by output_index saw the tool call's added/delta/done events land on an index it had already marked complete (the just-closed text message), and silently dropped them — the agent spoke its preamble and never executed the tool call, even though OmniRoute's own recorded responseBody had a complete, valid tool_calls entry. Covered by the new regression test in tests/unit/translator-resp-openai-responses.test.ts reproducing the exact live scenario.", + "_rebaseline_2026_08_03_9255_adobe_firefly_durable_sessions": "PR #9255 own cohesive growth: open-sse/services/adobeFireflyClient.ts 2322->2894 adds authenticated-vs-guest IMS classification, browser-risk ARP validation/rebuild, bounded 408 retry/recovery, sticky accepted-session handling, and matching image/video submit recovery at the existing Adobe upstream client chokepoints. This client was already explicitly frozen as a single self-contained upstream integration by #8006/#8510; splitting only the retry/auth helpers now would scatter one request state machine while structural shrink remains tracked in #3501. tests/unit/adobe-firefly.test.ts 871->1136 adds direct regression coverage for guest-token rejection, cookie/ARP rebuilding, 408 retries, sticky accepted ARP reuse, forced auth recovery, and cookie-to-IMS exchange. The obsolete 1179-line managed-Chrome fallback module was deleted rather than rebaselined after the packaged-safe pure-CDP path became authoritative. Focused Adobe suite: 61/61." } diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index a013c8526b..4df67f64e4 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -439,6 +439,12 @@ detection above). | `PROVIDER_LIMITS_SYNC_SPACING_MS` | `1500` | `src/lib/usage/providerLimits.ts` | Gap (ms) between consecutive OAuth quota fetches in a bulk sync; OAuth connections are fetched one at a time to avoid bursting an upstream. `0` opts out (concurrent). | | `OMNIROUTE_QUOTA_FETCH_MIN_INTERVAL_MS` | `250` | `open-sse/services/quotaFetchThrottle.ts` | Min interval (ms) between consecutive upstream quota fetches on the per-request preflight/monitor path; spaces concurrent network calls so many accounts on one IP don't burst the upstream. Wired into the Codex (`/wham/usage`), DeepSeek, Bailian (both fetch sites), OpenCode, and Crof quota fetchers (#6009, #6911). The generic `usage.ts::getUsageForProvider` dispatch path (github/glm/minimax/nanogpt/xai/etc.) is not yet covered — tracked separately. Cache hits unaffected. `0` disables; clamped `0..5000`. | | `PROVIDER_LIMITS_POST_USAGE_REFRESH_DELAY_MS` | `5000` | `src/lib/usage/providerLimits.ts` | Delay (ms) before refreshing provider limits after a real usage event, giving the upstream quota API time to register consumption. | +| `OMNIROUTE_LOGIN_BROWSER_PATH` | auto-detect | `open-sse/services/adobeFireflyBrowserLogin.ts` | Absolute path to a system Chrome or Edge executable used for interactive Adobe Firefly sign-in and off-screen renewal. | +| `ADOBE_FIREFLY_BROWSER_REFRESH` | enabled | `open-sse/services/adobeFireflySession.ts` | Keeps IMS and browser-risk state fresh with account-scoped Chrome CDP sessions. Set to `0` to disable browser renewal. | +| `ADOBE_FIREFLY_SESSION_DISK` | enabled | `open-sse/services/adobeFireflySession.ts` | Persists repaired Adobe sessions under `DATA_DIR` across process restarts. Set to `0` to keep sessions memory-only. | +| `ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS` | `12000` | `open-sse/services/adobeFireflySession.ts` | Minimum spacing in milliseconds between Adobe Firefly generate submissions; `0` disables spacing. | +| `ADOBE_FIREFLY_BATCH_EXTRA_GAP_MS` | `15000` | `open-sse/services/adobeFireflySession.ts` | Extra quiet period in milliseconds after every third successful Adobe submission. | +| `ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS` | `8000` | `open-sse/services/adobeFireflyClient.ts` | Base backoff in milliseconds after transient Adobe 408 responses; combined with submit spacing across at most five attempts. | | `OMNIROUTE_DISABLE_BACKGROUND_SERVICES` | `false` | `src/instrumentation-node.ts` | Disable all background services (sync, pricing, model refresh). Useful for CI/test. | | `OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS` | _(unset)_ | `src/lib/config/runtimeSettings.ts` | Force background tasks on under automated test detection. Set `1` to override the test heuristic. | | `OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS` | `600000` | `src/lib/jobs/budgetResetJob.ts` | Budget reset check cadence (ms). Floor `10000`. | diff --git a/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts b/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts index d4f6eb6da4..7d320a68ef 100644 --- a/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts +++ b/open-sse/handlers/imageGeneration/providers/adobeFirefly.ts @@ -15,14 +15,15 @@ import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGenerat import { AdobeFireflyError, adobeFireflyGenerateImage, - adobeFireflyImageTimeoutMs, - resolveAdobeAccessToken, - resolveAdobeSourceImageReferences, + resolveAdobeSourceImageIds, resolveAdobeImageModel, } from "../../../services/adobeFireflyClient.ts"; -import { getAdobeReferenceUploadLimit } from "../../../services/adobeFireflyModels.ts"; -import { isAdobeFireflyUpscaleModel } from "../../../services/adobeFireflyUpscale.ts"; -import { handleAdobeFireflyImageUpscale } from "../../imageUpscale/adobeFirefly.ts"; +import { ensureAdobeFireflySession } from "../../../services/adobeFireflySession.ts"; + +function normalizePositiveNumber(value: unknown, fallback: number): number { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? n : fallback; +} export async function handleAdobeFireflyImageGeneration({ model, @@ -50,25 +51,22 @@ 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; }) { const startTime = Date.now(); const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; - - // Topaz upscalers share adobe-firefly but use /v2/3p-images/upsample (no prompt). - if (isAdobeFireflyUpscaleModel(model)) { - return handleAdobeFireflyImageUpscale({ - model, - provider, - body: body as Record, - credentials, - log, - fetchImpl, - }); - } - if (!prompt) { return saveImageErrorResult({ provider, @@ -80,7 +78,17 @@ export async function handleAdobeFireflyImageGeneration({ } try { - const accessToken = await resolveAdobeAccessToken(credentials, fetchImpl); + // Durable session: JWT + Cookie once → auto-rebuild ARP from forter/arkose, + // cache, optional Playwright warm-up. Submit path rotates ARP on 408. + const session = await ensureAdobeFireflySession({ + credentials, + fetchImpl, + log, + }); + const accessToken = session.accessToken; + const sessionCookie = session.cookie || undefined; + const arpSessionId = session.arpSessionId; + const timeoutMs = normalizePositiveNumber(body.timeout_ms, 180_000); const seed = typeof body.seed === "number" ? body.seed @@ -88,44 +96,26 @@ export async function handleAdobeFireflyImageGeneration({ ? Number(body.seed) : undefined; - // Keep the raw credential blob for Cookie + sherlockToken (x-arp-session-id). - // JWT may be embedded in the same paste as cookies (HAR / multi-line). - const psd = (credentials as { providerSpecificData?: { cookie?: string } }) - ?.providerSpecificData; - const sessionCookie = - (typeof psd?.cookie === "string" && psd.cookie.trim()) || - (typeof credentials?.apiKey === "string" && credentials.apiKey.trim()) || - (typeof credentials?.accessToken === "string" && credentials.accessToken.includes(";") - ? credentials.accessToken - : undefined); + // 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 { spec } = resolveAdobeImageModel(model); - const references = await resolveAdobeSourceImageReferences({ + const sourceImageIds = await resolveAdobeSourceImageIds({ accessToken, body, - max: getAdobeReferenceUploadLimit(spec, "image"), + max: maxRefs, sessionCookie, + arpSessionId, prompt, fetchImpl, log, }); - const explicitTimeout = - typeof body.timeout_ms === "number" - ? body.timeout_ms - : typeof body.timeout_ms === "string" && body.timeout_ms.trim() - ? Number(body.timeout_ms) - : undefined; - const timeoutMs = adobeFireflyImageTimeoutMs({ - timeoutMs: explicitTimeout, - refCount: references.length, - }); - log?.info?.( "IMAGE", `${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` + - (references.length ? ` | refs: ${references.length}` : "") + - ` | pollTimeoutMs=${timeoutMs}` + (sourceImageIds.length ? ` | refs: ${sourceImageIds.length}` : "") + + ` | session=${session.source}` ); const result = await adobeFireflyGenerateImage({ @@ -137,8 +127,11 @@ export async function handleAdobeFireflyImageGeneration({ quality: body.quality, seed: Number.isFinite(seed as number) ? (seed as number) : undefined, negativePrompt: typeof body.negative_prompt === "string" ? body.negative_prompt : undefined, - references: references.length ? references : 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 b6f267e0bc..b812bb7b74 100644 --- a/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts +++ b/open-sse/handlers/videoGeneration/adobeFireflyHandler.ts @@ -9,11 +9,10 @@ import { sanitizeErrorMessage } from "../../utils/error.ts"; import { AdobeFireflyError, adobeFireflyGenerateVideo, - resolveAdobeAccessToken, - resolveAdobeSourceImageReferences, + resolveAdobeSourceImageIds, resolveAdobeVideoModel, } from "../../services/adobeFireflyClient.ts"; -import { getAdobeReferenceUploadLimit } from "../../services/adobeFireflyModels.ts"; +import { ensureAdobeFireflySession } from "../../services/adobeFireflySession.ts"; function normalizePositiveNumber(value: unknown, fallback: number): number { const n = Number(value); @@ -32,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; }) { @@ -47,7 +56,14 @@ export async function handleAdobeFireflyVideoGeneration({ } try { - const accessToken = await resolveAdobeAccessToken(credentials, fetchImpl); + const session = await ensureAdobeFireflySession({ + credentials, + fetchImpl, + log, + }); + const accessToken = session.accessToken; + const sessionCookie = session.cookie || undefined; + const arpSessionId = session.arpSessionId; const timeoutMs = normalizePositiveNumber(body.timeout_ms, 300_000); const seed = typeof body.seed === "number" @@ -55,22 +71,16 @@ export async function handleAdobeFireflyVideoGeneration({ : typeof body.seed === "string" && String(body.seed).trim() ? Number(body.seed) : undefined; - // Keep raw paste for Cookie + sherlockToken (x-arp-session-id). - const psd = (credentials as { providerSpecificData?: { cookie?: string } }) - ?.providerSpecificData; - const sessionCookie = - (typeof psd?.cookie === "string" && psd.cookie.trim()) || - (typeof credentials?.apiKey === "string" && credentials.apiKey.trim()) || - (typeof credentials?.accessToken === "string" && credentials.accessToken.includes(";") - ? credentials.accessToken - : undefined); - const { spec } = resolveAdobeVideoModel(String(model)); - const references = await resolveAdobeSourceImageReferences({ + // Kling i2v / Veo ref / Sora frame: upload reference images first. + const { id: videoModelId } = resolveAdobeVideoModel(String(model)); + const maxFrames = videoModelId.includes("kling") || videoModelId.includes("sora") ? 2 : 3; + const sourceImageIds = await resolveAdobeSourceImageIds({ accessToken, body, - max: getAdobeReferenceUploadLimit(spec, "image"), + max: maxFrames, sessionCookie, + arpSessionId, prompt, fetchImpl, log, @@ -79,7 +89,8 @@ export async function handleAdobeFireflyVideoGeneration({ log?.info?.( "VIDEO", `${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` + - (references.length ? ` | refs: ${references.length}` : "") + (sourceImageIds.length ? ` | frames: ${sourceImageIds.length}` : "") + + ` | session=${session.source}` ); const result = await adobeFireflyGenerateVideo({ @@ -99,8 +110,11 @@ export async function handleAdobeFireflyVideoGeneration({ ? body.negativePrompt : undefined, generateAudio: body.generate_audio !== false && body.generateAudio !== false, - references: references.length ? references : undefined, + sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined, 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 1482971c3e..eb59090edf 100644 --- a/open-sse/services/adobeFireflyBrowserLogin.ts +++ b/open-sse/services/adobeFireflyBrowserLogin.ts @@ -3,42 +3,202 @@ * * Firefly needs an Adobe IMS access_token JWT (Bearer) issued for * client_id `clio-playground-web`. That JWT is NEVER present in - * cookies/localStorage тАФ the SPA only holds it in memory and attaches it + * 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. + * Protocol over WebSocket — zero Playwright dependency. */ import { spawn, type ChildProcess } from "node:child_process"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import http from "node:http"; import { createServer } from "node:net"; -import { tmpdir } from "node:os"; import { join } from "node:path"; +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; credentials?: { accessToken?: string; cookie?: string }; - /** Best-effort Adobe account label (email or user id) decoded from the JWT. */ + arpSessionId?: string; + /** Human-readable Adobe account label resolved from IMS userinfo. */ account?: string; error?: string; } +export interface AdobeFireflyCdpRefreshResult { + accessToken: string; + cookie: string; + arpSessionId: string; +} + +type AdobeFireflyBrowserLog = { + info?: (...args: unknown[]) => void; + warn?: (...args: unknown[]) => void; +}; + +/** + * 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 = + String(process.env.DATA_DIR || process.env.OMNIROUTE_DATA_DIR || "").trim() || + (process.env.LOCALAPPDATA + ? join(process.env.LOCALAPPDATA, "OmniRoute") + : join(process.cwd(), ".data")); + mkdirSync(dataRoot, { recursive: true }); + return dataRoot; +} + +export function adobeFireflyBrowserSessionKey(value: unknown): string { + const raw = String(value || "legacy-default").trim() || "legacy-default"; + return createHash("sha256").update(raw).digest("hex").slice(0, 32); +} + +/** Chrome 136+ requires a non-default user-data-dir for remote debugging. */ +export function resolveAdobeFireflyBrowserProfileDir(sessionKey?: string): string { + const profile = join( + resolveAdobeFireflyDataRoot(), + "adobe-chrome-profiles", + adobeFireflyBrowserSessionKey(sessionKey) + ); + 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 +214,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( @@ -69,23 +237,56 @@ export function buildAdobeFireflyCookieHeader( return parts.join("; "); } -/** Best-effort account label from an IMS JWT payload. Exported for unit tests. */ +function humanAdobeLabel(value: unknown): string { + const label = typeof value === "string" ? value.trim() : ""; + if (!label || /@(Adobe|Guest)ID$/i.test(label)) return ""; + return label; +} + +/** Human-readable label claims only; opaque Adobe IDs are intentionally excluded. */ export function accountLabelFromAdobeJwt(token: string): string { - try { - const part = String(token || "").split(".")[1]; - if (!part) return ""; - const json = Buffer.from(part.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"); - const obj = JSON.parse(json) as Record; - for (const key of ["email", "preferred_username", "user_id", "sub"]) { - const v = obj[key]; - if (typeof v === "string" && v.trim()) return v.trim(); - } - } catch { - // ignore + const obj = decodeAdobeJwtPayload(token); + if (!obj) return ""; + for (const key of ["email", "preferred_username", "name", "display_name"]) { + const label = humanAdobeLabel(obj[key]); + if (label) return label; } return ""; } +/** Resolve email/display name from Adobe IMS; never expose the opaque user_id as a label. */ +export async function resolveAdobeAccountLabel( + token: string, + fetchImpl: typeof fetch = fetch +): Promise { + const claimLabel = accountLabelFromAdobeJwt(token); + const payload = decodeAdobeJwtPayload(token); + const clientId = humanAdobeLabel(payload?.client_id) || "clio-playground-web"; + try { + const response = await fetchImpl( + `https://ims-na1.adobelogin.com/ims/userinfo/v2?client_id=${encodeURIComponent(clientId)}`, + { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(10_000), + } + ); + if (response.ok) { + const user = (await response.json()) as Record; + for (const key of ["email", "preferred_username", "name", "display_name"]) { + const label = humanAdobeLabel(user[key]); + if (label) return label; + } + const given = humanAdobeLabel(user.given_name); + const family = humanAdobeLabel(user.family_name); + const full = [given, family].filter(Boolean).join(" ").trim(); + if (full) return full; + } + } catch { + // JWT label or generic fallback below keeps login successful if userinfo is unavailable. + } + return claimLabel || "Adobe account"; +} + /** Resolve system Chrome/Edge executable. Exported for unit tests. */ export function resolveSystemBrowserExecutable(): string | null { const configured = process.env.OMNIROUTE_LOGIN_BROWSER_PATH?.trim(); @@ -141,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); } @@ -159,7 +360,99 @@ async function waitForCdpReady( throw new Error(`Chrome DevTools did not become ready: ${lastError}`); } -type CdpCookie = { name: string; value: string; domain?: string }; +export type AdobeBrowserCookie = { + name: string; + value: string; + domain?: string; + path?: string; + expires?: number; + httpOnly?: boolean; + secure?: boolean; + sameSite?: "Strict" | "Lax" | "None"; +}; + +type CdpCookie = AdobeBrowserCookie; + +function isAdobeCookieDomain(domain: string | undefined): boolean { + const value = String(domain || "") + .trim() + .replace(/^\./, "") + .toLowerCase(); + return ( + value === "adobe.com" || + value.endsWith(".adobe.com") || + value === "adobelogin.com" || + value.endsWith(".adobelogin.com") || + value === "adobe.io" || + value.endsWith(".adobe.io") + ); +} + +export function filterAdobeBrowserCookies(cookies: CdpCookie[]): AdobeBrowserCookie[] { + return cookies + .filter( + (cookie) => + isAdobeCookieDomain(cookie.domain) && + Boolean(cookie.name && cookie.value) && + !/[\r\n\0]/.test(cookie.name + cookie.value) + ) + .map((cookie) => ({ + name: cookie.name, + value: cookie.value, + ...(cookie.domain ? { domain: cookie.domain } : {}), + path: cookie.path || "/", + ...(typeof cookie.expires === "number" ? { expires: cookie.expires } : {}), + ...(typeof cookie.httpOnly === "boolean" ? { httpOnly: cookie.httpOnly } : {}), + ...(typeof cookie.secure === "boolean" ? { secure: cookie.secure } : {}), + ...(cookie.sameSite ? { sameSite: cookie.sameSite } : {}), + })); +} + +function adobeBrowserCookieJarPath(sessionKey: string): string { + const dir = join(resolveAdobeFireflyDataRoot(), "adobe-browser-sessions"); + mkdirSync(dir, { recursive: true }); + return join(dir, `${adobeFireflyBrowserSessionKey(sessionKey)}.json`); +} + +function loadAdobeBrowserCookies(sessionKey: string): AdobeBrowserCookie[] { + try { + const path = adobeBrowserCookieJarPath(sessionKey); + if (!existsSync(path)) return []; + const parsed = JSON.parse(readFileSync(path, "utf8")); + return Array.isArray(parsed) ? filterAdobeBrowserCookies(parsed as CdpCookie[]) : []; + } catch { + return []; + } +} + +function saveAdobeBrowserCookies(sessionKey: string, cookies: CdpCookie[]): void { + try { + writeFileSync( + adobeBrowserCookieJarPath(sessionKey), + JSON.stringify(filterAdobeBrowserCookies(cookies)), + "utf8" + ); + } catch { + // Best-effort: login still returns the portable JWT + Firefly risk cookies. + } +} + +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; @@ -197,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))); } @@ -214,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 { @@ -244,126 +564,466 @@ 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; browserWsUrl: string; timeoutMs: number; -}): Promise<{ accessToken: string; cookies: CdpCookie[] }> { + fallbackAccessToken?: string; + seedCookie?: string; + seedBrowserCookies?: AdobeBrowserCookie[]; + waitForRiskRefresh?: boolean; +}): Promise<{ + accessToken: string; + cookies: CdpCookie[]; + arpSessionId: string; +}> { let capturedAccessToken = ""; - const pageSockets = new Map(); + let storageAccessToken = ""; + let capturedArpSessionId = ""; + let latestCookies: CdpCookie[] = []; + /** 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; + 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 || "" + ); + 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") { - if (capturedAccessToken) return; const request = params.request as { url?: string; headers?: Record } | undefined; - if (!request?.url) return; - let host: string; - try { - host = new URL(request.url).hostname.toLowerCase(); - } catch { - return; - } - if (host !== FIREFLY_3P_HOST_SUFFIX && !host.endsWith(`.${FIREFLY_3P_HOST_SUFFIX}`)) return; + if (!request?.url || !request.url.includes(FIREFLY_3P_HOST_SUFFIX)) return; const headers = request.headers || {}; const auth = headers.Authorization || headers.authorization || headers.AUTHORIZATION || ""; const token = extractAdobeBearerTokenFromAuthorization(auth); - if (token) capturedAccessToken = token; + if (token && isAdobeUserAccessToken(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; - 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); + // 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 as const, + })), + ]; + const seed: AdobeBrowserCookie[] = ( + requireFreshRisk ? filterSeedCookiesForWarm(rawSeed, { dropRiskCookies: true }) : rawSeed + ) as AdobeBrowserCookie[]; + if (seed.length > 0) { + await browserCdp + .send("Storage.setCookies", { + cookies: seed.map((cookie) => ({ + name: cookie.name, + value: cookie.value, + ...(cookie.domain ? { domain: cookie.domain } : { url: FIREFLY_HOME_URL }), + path: cookie.path || "/", + ...(typeof cookie.expires === "number" && cookie.expires > 0 + ? { expires: cookie.expires } + : {}), + ...(typeof cookie.httpOnly === "boolean" ? { httpOnly: cookie.httpOnly } : {}), + ...(typeof cookie.sameSite === "string" ? { sameSite: cookie.sameSite } : {}), + secure: cookie.secure !== false, + })), + }) + .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 (!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 } - 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[]; + 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 */ + } + + // 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 || + storageAccessToken || + (isAdobeUserAccessToken(fallbackToken) ? fallbackToken : ""); + if (accessToken) { + 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")) + ); + // 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: capturedAccessToken, - cookies: Array.isArray(result?.cookies) ? result.cookies : [], + accessToken, + cookies: latestCookies, + arpSessionId: capturedArpSessionId, }; - } catch { - /* try next */ } } - return { accessToken: capturedAccessToken, cookies: [] }; } 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 && !requireFreshRisk) { + return { + accessToken: capturedAccessToken || storageAccessToken || 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." ); } 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(() => { @@ -383,14 +1043,83 @@ 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", + // 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). */ -export async function startAdobeFireflyBrowserLogin( - requestedTimeout?: unknown -): Promise { - const timeout = clampAdobeFireflyLoginTimeout(requestedTimeout); +async function runAdobeFireflyCdpBrowser(opts: { + timeoutMs: number; + interactive: boolean; + sessionKey: string; + freshSession?: boolean; + seedCookie?: string; + accessToken?: string; + log?: AdobeFireflyBrowserLog; +}): Promise { const browserPath = resolveSystemBrowserExecutable(); if (!browserPath) { return { @@ -402,64 +1131,152 @@ export async function startAdobeFireflyBrowserLogin( }; } - let userDataDir: string | null = null; let child: ChildProcess | null = null; try { - userDataDir = mkdtempSync(join(tmpdir(), "omniroute-firefly-login-")); - const port = await getFreeLoopbackPort(); + const userDataDir = resolveAdobeFireflyBrowserProfileDir(opts.sessionKey); + // 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 = [ - `--remote-debugging-port=${port}`, - `--user-data-dir=${userDataDir}`, - "--no-first-run", - "--no-default-browser-check", - "--disable-sync", - "--disable-background-networking", - "--window-size=1280,800", - FIREFLY_HOME_URL, - ]; - - child = spawn(browserPath, args, { - stdio: "ignore", - windowsHide: false, - 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: timeout, - }), - earlyExit, - ]); + userDataDir: attemptUserDataDir, + interactive: opts.interactive, + freshSession: opts.freshSession, + }); - const cookie = buildAdobeFireflyCookieHeader(captured.cookies); - const account = accountLabelFromAdobeJwt(captured.accessToken); + // 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 } : {}), - }, - ...(account ? { account } : {}), + success: false, + error: sanitizeErrorMessage(lastError), }; } catch (error) { return { @@ -467,16 +1284,78 @@ export async function startAdobeFireflyBrowserLogin( 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; - 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, + opts?: { sessionKey?: string; freshSession?: boolean } +): Promise { + // 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, + sessionKey: String(opts?.sessionKey || "legacy-default"), + freshSession: opts?.freshSession !== false, + }) + ); + interactiveCdpChain = 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; + sessionKey?: string; +}): Promise { + 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, + sessionKey: String(opts.sessionKey || "legacy-default"), + 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) { + opts.log?.warn?.( + "ADOBE-FIREFLY", + `CDP background refresh incomplete: ${result.error || "missing token/cookie"}` + ); + return null; } + return { + accessToken, + cookie, + arpSessionId: String(result.arpSessionId || "").trim(), + }; + }); + backgroundCdpChain = 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/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 d2bd3e3dc8..34f1fd2e3a 100644 --- a/open-sse/services/adobeFireflyClient.ts +++ b/open-sse/services/adobeFireflyClient.ts @@ -24,22 +24,6 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; import { resolvePublicCred } from "../utils/publicCreds.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; -import { - ADOBE_FIREFLY_IMAGE_MODELS as DISCOVERED_IMAGE_MODELS, - ADOBE_FIREFLY_VIDEO_MODELS as DISCOVERED_VIDEO_MODELS, - parseAdobeModelsDiscovery, - resolveAdobeImageModel as resolveDiscoveredImageModel, - resolveAdobeVideoModel as resolveDiscoveredVideoModel, - type AdobeFireflyCatalogModel as DiscoveredCatalogModel, - type AdobeFireflyImageModelSpec as DiscoveredImageModelSpec, - type AdobeFireflyVideoModelSpec as DiscoveredVideoModelSpec, -} from "./adobeFireflyModels.ts"; - -export { - DISCOVERED_IMAGE_MODELS as ADOBE_FIREFLY_IMAGE_MODELS, - DISCOVERED_VIDEO_MODELS as ADOBE_FIREFLY_VIDEO_MODELS, - parseAdobeModelsDiscovery, -}; export const ADOBE_FIREFLY_IMAGE_SUBMIT_URL = "https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async"; @@ -62,35 +46,171 @@ 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_POLL_INTERVAL_MS = 3000; -/** - * Poll budget for image generate-async. Multi-ref gpt-image / nano jobs commonly - * exceed 3 minutes (upload + colligo + render at detailLevel 5). 180s was the - * previous default and produced widespread 504s on listing assets with screenshots. - */ -export const DEFAULT_IMAGE_TIMEOUT_MS = 300_000; +const DEFAULT_IMAGE_TIMEOUT_MS = 180_000; const DEFAULT_VIDEO_TIMEOUT_MS = 300_000; -/** Extra poll budget per uploaded reference blob (large screenshots + image2image). */ -export const ADOBE_FIREFLY_IMAGE_TIMEOUT_PER_REF_MS = 60_000; -export const ADOBE_FIREFLY_IMAGE_TIMEOUT_MAX_MS = 600_000; const FIREFLY_ORIGIN = "https://firefly.adobe.com"; const FIREFLY_REFERER = "https://firefly.adobe.com/"; -/** - * Resolve poll timeout: explicit body.timeout_ms wins; else base + per-ref budget. - * Covers multi-screenshot listing jobs without unbounded waits. - */ -export function adobeFireflyImageTimeoutMs(opts?: { - timeoutMs?: number; - refCount?: number; -}): number { - const explicit = Number(opts?.timeoutMs); - if (Number.isFinite(explicit) && explicit > 0) { - return Math.min(ADOBE_FIREFLY_IMAGE_TIMEOUT_MAX_MS, Math.floor(explicit)); - } - const refs = Math.max(0, Math.floor(Number(opts?.refCount) || 0)); - const budget = DEFAULT_IMAGE_TIMEOUT_MS + refs * ADOBE_FIREFLY_IMAGE_TIMEOUT_PER_REF_MS; - return Math.min(ADOBE_FIREFLY_IMAGE_TIMEOUT_MAX_MS, budget); +export type AdobeFireflyImageModelId = + | "nano-banana-pro" + | "nano-banana" + | "nano-banana-2" + | "gpt-image" + | "gpt-image-2" + | "gpt-image-1.5" + | "flux-2" + | "flux-pro" + | "flux-ultra" + | "seedream-4" + | "seedream-5-lite" + | "runway-gen4-image"; + +export type AdobeFireflyVideoModelId = + "sora-2" | "sora-2-pro" | "veo-3.1" | "veo-3.1-fast" | "veo-3.1-ref" | "kling-3"; + +export interface AdobeFireflyImageModelSpec { + upstreamModelId: string; + upstreamModelVersion: string; + /** Payload builder family — nano uses Gemini-style size maps; gpt-image uses OpenAI detail levels. */ + family: "nano" | "gpt-image" | "generic"; } + +export interface AdobeFireflyVideoModelSpec { + engine: "sora2" | "sora2-pro" | "veo31-standard" | "veo31-fast" | "kling3"; + upstreamModel: string; + modelId?: string; + modelVersion?: string; + referenceMode?: "frame" | "image"; + defaultDuration: number; + defaultResolution: string; +} + +/** + * 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< + 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< + 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": { "1:1": { width: 1024, height: 1024 }, @@ -218,7 +338,7 @@ export function decodeAdobeJwtPayload(token: string): Record | .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_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}/); + const m = raw.match(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/); if (m) raw = m[0]; const part = raw.split(".")[1]; if (!part) return null; @@ -321,9 +441,7 @@ export function extractAdobeCredentialToken(raw: string): string { if (authMatch?.[1] && looksLikeAdobeJwt(authMatch[1])) return authMatch[1]; // Any eyJ… JWT in the blob (HAR / multi-line). Prefer user AdobeID tokens. - const jwtMatches = value.match( - /eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}/g - ); + const jwtMatches = value.match(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g); if (jwtMatches && jwtMatches.length > 0) { const sorted = [...jwtMatches].sort((a, b) => b.length - a.length); const user = sorted.find((t) => looksLikeAdobeJwt(t) && isAdobeUserAccessToken(t)); @@ -372,15 +490,14 @@ export function extractAdobeCookieHeader(raw: string): string { if (/^bearer\s+/i.test(line)) return false; if (looksLikeAdobeJwt(line)) return false; // Drop standalone eyJ… segments - if (/^eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}$/.test(line)) - return false; + if (/^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(line)) return false; return true; }) .join("; "); // Also strip inline eyJ JWT tokens that may sit inside a cookie string const noJwt = cleaned - .replace(/eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}/g, "") + .replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "") .replace(/;\s*;/g, ";") .replace(/^;\s*|\s*;$/g, "") .trim(); @@ -450,32 +567,145 @@ export function normalizeAdobeOutputResolution( return "2K"; } -export function resolveAdobeImageModel( - model: string -): ReturnType { - try { - return resolveDiscoveredImageModel(model); - } catch (error) { - throw new AdobeFireflyError( - error instanceof Error ? error.message : "Unknown Adobe Firefly image model", - 400, - "unknown_model" - ); +export function resolveAdobeImageModel(model: string): { + id: AdobeFireflyImageModelId; + spec: AdobeFireflyImageModelSpec; +} { + const raw = String(model || "") + .trim() + .toLowerCase() + .replace(/^adobe-firefly\//, "") + .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-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"], + }; + } + 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"], + }; + } + // Prefer explicit "2" / "gpt-image-2" before generic gpt-image + if ( + raw === "gpt-image-2" || + raw.includes("gpt-image-2") || + raw.includes("gptimage2") || + raw === "gpt-image" || + raw.includes("gpt-image") + ) { + // 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"], + }; + } + 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"] }; + } + if (raw.includes("flux-pro") || raw.includes("fluxpro")) { + return { id: "flux-pro", spec: ADOBE_FIREFLY_IMAGE_MODELS["flux-pro"] }; + } + if (raw.includes("flux")) { + 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"], + }; + } + 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"], + }; + } + + if (raw in ADOBE_FIREFLY_IMAGE_MODELS) { + const id = raw as AdobeFireflyImageModelId; + return { id, spec: ADOBE_FIREFLY_IMAGE_MODELS[id] }; + } + + // Default to Nano Banana Pro (most common Firefly image path). + return { + id: "nano-banana-pro", + spec: ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"], + }; } -export function resolveAdobeVideoModel( - model: string -): ReturnType { - try { - return resolveDiscoveredVideoModel(model); - } catch (error) { - throw new AdobeFireflyError( - error instanceof Error ? error.message : "Unknown Adobe Firefly video model", - 400, - "unknown_model" - ); +export function resolveAdobeVideoModel(model: string): { + id: AdobeFireflyVideoModelId; + spec: AdobeFireflyVideoModelSpec; +} { + const raw = String(model || "") + .trim() + .toLowerCase() + .replace(/^adobe-firefly\//, "") + .replace(/^firefly\//, ""); + + if (raw.includes("sora2-pro") || raw.includes("sora-2-pro") || raw.includes("sora2_pro")) { + return { id: "sora-2-pro", spec: ADOBE_FIREFLY_VIDEO_MODELS["sora-2-pro"] }; } + if (raw.includes("sora2") || raw.includes("sora-2") || raw.includes("sora")) { + 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"], + }; + } + 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"], + }; + } + 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"] }; + } + if (raw.includes("kling")) { + return { id: "kling-3", spec: ADOBE_FIREFLY_VIDEO_MODELS["kling-3"] }; + } + + if (raw in ADOBE_FIREFLY_VIDEO_MODELS) { + const id = raw as AdobeFireflyVideoModelId; + return { id, spec: ADOBE_FIREFLY_VIDEO_MODELS[id] }; + } + + return { id: "sora-2", spec: ADOBE_FIREFLY_VIDEO_MODELS["sora-2"] }; } /** @@ -485,138 +715,29 @@ export function resolveAdobeVideoModel( * Explicit low/medium still honor the caller's choice. */ function gptDetailLevel(quality: unknown): number { - const q = String(quality ?? "high") + // Live firefly.adobe.com default for gpt-image is detailLevel 3 (medium). + const q = String(quality ?? "medium") .trim() .toLowerCase(); - if (q === "low" || q === "1k" || q === "1") return 1; - if (q === "medium" || q === "2k" || q === "standard" || q === "hd" || q === "3") return 3; - // high / 4k / ultra / auto / empty / unknown → max detail - return 5; -} - -export interface AdobeFireflyReferenceBlob { - id: string; - mediaType?: string; - usage?: string; - order?: number; -} - -function defaultAdobeReferenceUsage( - model: DiscoveredCatalogModel, - mediaType: string -): string | null { - const supported = model.capabilities.referenceInputs - .filter((capability) => capability.mediaType === mediaType) - .map((capability) => capability.usageType); - const priority = - model.modality === "video" - ? ["frame", "element", "style", "subject", "source", "general"] - : ["source", "general", "style", "element", "subject"]; - return priority.find((usage) => supported.includes(usage)) || supported[0] || null; -} - -/** Validate roles/counts against the resolved discovery schema and produce wire blobs. */ -export function normalizeAdobeReferenceBlobs( - model: DiscoveredCatalogModel, - references: AdobeFireflyReferenceBlob[] = [], - fallbackImageIds: string[] = [] -): Array> { - const requested = references.length - ? references - : fallbackImageIds.map((id) => ({ id, mediaType: "image" })); - const capabilities = model.capabilities.referenceInputs; - const totalLimit = model.capabilities.maxReferenceItems; - if (totalLimit !== null && requested.length > totalLimit) { - throw new AdobeFireflyError( - `${model.name} accepts at most ${totalLimit} total reference item(s)`, - 400, - "invalid_reference_count" - ); - } - - const counts = new Map(); - const normalized = requested.map((reference) => { - const id = String(reference.id || "").trim(); - if (!id) { - throw new AdobeFireflyError( - "Adobe Firefly reference id is required", - 400, - "invalid_reference" - ); - } - const mediaType = String(reference.mediaType || "image") - .trim() - .toLowerCase(); - const usage = - String(reference.usage || "") - .trim() - .toLowerCase() || defaultAdobeReferenceUsage(model, mediaType); - const capability = capabilities.find( - (candidate) => candidate.mediaType === mediaType && candidate.usageType === usage - ); - if (!usage || !capability) { - const allowed = capabilities - .filter((candidate) => candidate.mediaType === mediaType) - .map((candidate) => candidate.usageType) - .join(", "); - throw new AdobeFireflyError( - `${model.name} does not support ${mediaType} references with usage '${usage || "unspecified"}'` + - (allowed ? ` (allowed: ${allowed})` : ""), - 400, - "invalid_reference_usage" - ); - } - const key = `${mediaType}:${usage}`; - const count = (counts.get(key) || 0) + 1; - counts.set(key, count); - if (capability.maxItems !== null && count > capability.maxItems) { - throw new AdobeFireflyError( - `${model.name} accepts at most ${capability.maxItems} ${usage} ${mediaType} reference(s)`, - 400, - "invalid_reference_count" - ); - } - return { - id, - usage, - ...(usage === "frame" ? { order: reference.order ?? count } : {}), - }; - }); - - for (const capability of capabilities) { - if (capability.minItems <= 0) continue; - const key = `${capability.mediaType}:${capability.usageType}`; - const count = counts.get(key) || 0; - if (count < capability.minItems) { - throw new AdobeFireflyError( - `${model.name} requires at least ${capability.minItems} ${capability.usageType} ${capability.mediaType} reference(s)`, - 400, - "missing_required_reference" - ); - } - } - return normalized; + 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; + return 3; } export function buildAdobeImagePayload(opts: { prompt: string; aspectRatio: string; outputResolution: "1K" | "2K" | "4K"; - modelSpec: DiscoveredImageModelSpec; + modelSpec: AdobeFireflyImageModelSpec; quality?: unknown; seed?: number; sourceImageIds?: string[]; - references?: AdobeFireflyReferenceBlob[]; negativePrompt?: string; }): Record { const ratio = opts.aspectRatio === "auto" ? "1:1" : opts.aspectRatio || "1:1"; const seeds = [typeof opts.seed === "number" ? opts.seed : Math.floor(Date.now() % 999999)]; const negative = String(opts.negativePrompt || "").trim(); - const referenceBlobs = normalizeAdobeReferenceBlobs( - opts.modelSpec, - opts.references, - opts.sourceImageIds - ); const genSettings: Record = {}; if (negative) { genSettings.avoidKeywords = negative @@ -638,22 +759,28 @@ 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, }, }; - if (referenceBlobs.length) { - // gpt-image references use the roles/counts validated from discovery. + if (opts.sourceImageIds?.length) { + // gpt-image subject references (mask path uses separate mask blob when present). payload.generationMetadata = { module: "image2image", submodule: "ff-image-generate" }; - payload.referenceBlobs = referenceBlobs; + payload.referenceBlobs = opts.sourceImageIds.map((id) => ({ + id: String(id), + usage: "subject", + })); payload.modelSpecificPayload = {}; } return payload; } - // Gemini Flash + generic (Flux / Seedream / Runway image): same 3P image shape. + // nano (Gemini Flash) + generic (Flux / Seedream / Runway image): same 3P image shape. // Live capture (web_providers/adobe_atach_images.txt): referenceBlobs with usage "general" // keep module "text2image" (not image2image) for nano multi-ref composition. const sizeMap = NANO_SIZE_MAP[opts.outputResolution] || NANO_SIZE_MAP["2K"]; @@ -668,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, @@ -677,11 +807,17 @@ export function buildAdobeImagePayload(opts: { }; if (Object.keys(genSettings).length) payload.generationSettings = genSettings; - if (referenceBlobs.length) { - payload.referenceBlobs = referenceBlobs; - // Flux / Seedream / Runway image historically used image2image; Gemini keeps text2image. + if (opts.sourceImageIds?.length) { + payload.referenceBlobs = opts.sourceImageIds.map((id) => ({ + id: String(id), + usage: "general", + })); + // 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; @@ -696,125 +832,149 @@ function videoSize(aspectRatio: string, resolution: string): { width: number; he return { width: Math.round((short * 16) / 9), height: short }; } -function parseSize(value: string): { width: number; height: number } | null { - const match = String(value || "").match(/^(\d+)[x:](\d+)$/i); - if (!match) return null; - const width = Number(match[1]); - const height = Number(match[2]); - return width > 0 && height > 0 ? { width, height } : null; -} - -function selectAdobeVideoSize( - model: DiscoveredVideoModelSpec, - aspectRatio: string, - resolution: string -): { width: number; height: number } { - const supported = model.capabilities.supportedSizes.map(parseSize).filter(Boolean) as Array<{ - width: number; - height: number; - }>; - const requestedExact = parseSize(aspectRatio); - if (requestedExact) { - const exact = supported.find( - (size) => size.width === requestedExact.width && size.height === requestedExact.height - ); - if (exact) return exact; - } - if (supported.length === 0) return videoSize(aspectRatio, resolution); - - const [ratioWidth, ratioHeight] = String(aspectRatio || "16:9") - .split(":") - .map(Number); - const targetRatio = ratioWidth > 0 && ratioHeight > 0 ? ratioWidth / ratioHeight : 16 / 9; - const targetLongEdge = String(resolution).includes("1080") ? 1920 : 1280; - return [...supported].sort((left, right) => { - const leftScore = - Math.abs(left.width / left.height - targetRatio) * 10_000 + - Math.abs(Math.max(left.width, left.height) - targetLongEdge); - const rightScore = - Math.abs(right.width / right.height - targetRatio) * 10_000 + - Math.abs(Math.max(right.width, right.height) - targetLongEdge); - return leftScore - rightScore; - })[0]; -} - -function validateAdobeDuration(model: DiscoveredVideoModelSpec, requested: number): number { - const capabilities = model.capabilities; - const duration = Math.floor(Number.isFinite(requested) ? requested : model.defaultDuration); - if ( - capabilities.supportedDurations.length > 0 && - !capabilities.supportedDurations.includes(duration) - ) { - throw new AdobeFireflyError( - `${model.name} supports duration(s): ${capabilities.supportedDurations.join(", ")} seconds`, - 400, - "invalid_duration" - ); - } - if ( - (capabilities.durationMin !== null && duration < capabilities.durationMin) || - (capabilities.durationMax !== null && duration > capabilities.durationMax) - ) { - throw new AdobeFireflyError( - `${model.name} duration must be between ${capabilities.durationMin ?? "?"} and ${capabilities.durationMax ?? "?"} seconds`, - 400, - "invalid_duration" - ); - } - return duration; -} - export function buildAdobeVideoPayload(opts: { prompt: string; aspectRatio: string; duration: number; - modelSpec: DiscoveredVideoModelSpec; + modelSpec: AdobeFireflyVideoModelSpec; resolution?: string; seed?: number; sourceImageIds?: string[]; - references?: AdobeFireflyReferenceBlob[]; negativePrompt?: string; generateAudio?: boolean; }): Record { - const model = opts.modelSpec; - const properties = new Set(model.capabilities.schemaProperties); + const seedVal = typeof opts.seed === "number" ? opts.seed : Math.floor(Date.now() % 999999); const aspect = opts.aspectRatio === "auto" ? "16:9" : opts.aspectRatio || "16:9"; - if ( - model.capabilities.supportedAspectRatios.length > 0 && - !model.capabilities.supportedAspectRatios.includes(aspect) - ) { - throw new AdobeFireflyError( - `${model.name} supports aspect ratio(s): ${model.capabilities.supportedAspectRatios.join(", ")}`, - 400, - "invalid_aspect_ratio" - ); - } - const duration = validateAdobeDuration(model, opts.duration); - const referenceBlobs = normalizeAdobeReferenceBlobs(model, opts.references, opts.sourceImageIds); - const seed = typeof opts.seed === "number" ? opts.seed : Math.floor(Date.now() % 999999); - const resolution = opts.resolution || model.defaultResolution; - const payload: Record = { - modelId: model.upstreamModelId, - modelVersion: model.upstreamModelVersion, - prompt: opts.prompt, - generationMetadata: { - module: referenceBlobs.some((reference) => reference.usage === "frame") - ? "image2video" - : "text2video", - }, - }; + 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; + const sourceImageIds = opts.sourceImageIds || []; + const negative = String(opts.negativePrompt || ""); - if (properties.has("n")) payload.n = 1; - if (properties.has("seeds")) payload.seeds = [seed]; - if (properties.has("output")) payload.output = { storeInputs: true }; - if (properties.has("size")) payload.size = selectAdobeVideoSize(model, aspect, resolution); - if (properties.has("duration")) payload.duration = duration; - if (properties.has("generationSettings")) payload.generationSettings = { aspectRatio: aspect }; - if (properties.has("generateAudio")) payload.generateAudio = opts.generateAudio !== false; - if (properties.has("modelSpecificPayload")) payload.modelSpecificPayload = {}; - if (properties.has("referenceBlobs")) payload.referenceBlobs = referenceBlobs; - const negativePrompt = String(opts.negativePrompt || "").trim(); - if (negativePrompt && properties.has("negativePrompt")) payload.negativePrompt = negativePrompt; + if (engine === "veo31-standard" || engine === "veo31-fast") { + const payload: Record = { + n: 1, + seeds: [seedVal], + modelId: "veo", + modelVersion: + opts.modelSpec.modelVersion || + (engine === "veo31-fast" ? "3.1-fast-generate" : "3.1-generate"), + output: { storeInputs: true }, + prompt: opts.prompt, + size: vidSize, + generateAudio: opts.generateAudio !== false, + referenceBlobs: [] as Array>, + generationMetadata: { module: "text2video" }, + modelSpecificPayload: { + parameters: { + durationSeconds: duration, + aspectRatio: aspect, + addWaterMark: false, + }, + }, + }; + if (sourceImageIds.length) { + const refs = payload.referenceBlobs as Array>; + if (opts.modelSpec.referenceMode === "image") { + for (const imageId of sourceImageIds.slice(0, 3)) { + refs.push({ id: String(imageId), usage: "asset" }); + } + } else { + sourceImageIds.slice(0, 2).forEach((imageId, idx) => { + refs.push({ id: String(imageId), usage: "general", order: idx + 1 }); + }); + } + payload.generationMetadata = { module: "image2video" }; + } + if (negative) payload.negativePrompt = negative; + return payload; + } + + if (engine === "kling3") { + const payload: Record = { + n: 1, + seeds: [seedVal], + modelId: "kling", + modelVersion: "kling_v3_standard_i2v", + output: { storeInputs: true }, + prompt: opts.prompt, + size: vidSize, + generationMetadata: { + module: sourceImageIds.length ? "image2video" : "text2video", + }, + duration, + generationSettings: { aspectRatio: aspect }, + referenceBlobs: [] as Array>, + }; + if (sourceImageIds.length) { + const refs = payload.referenceBlobs as Array>; + sourceImageIds.slice(0, 2).forEach((imageId, idx) => { + refs.push({ id: String(imageId), usage: "frame", order: idx + 1 }); + }); + } + if (negative) payload.negativePrompt = negative; + return payload; + } + + // Sora 2 / Sora 2 Pro + const promptJson = JSON.stringify({ + prompt: opts.prompt, + duration, + ...(negative ? { negative_prompt: negative } : {}), + }); + const payload: Record = { + n: 1, + seeds: [seedVal], + modelId: "sora", + modelVersion: engine === "sora2-pro" ? "sora-2-pro" : "sora-2", + size: vidSize, + duration, + fps: 24, + prompt: promptJson, + 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, + }, + negativePrompt: negative, + jobMode: "standard", + debugGenerationEndpoint: "", + referenceBlobs: [] as Array>, + referenceFrames: [] as Array | null>, + referenceVideo: null, + cameraMotionReferenceVideo: null, + characterReference: null, + editReferenceVideo: null, + output: { storeInputs: true }, + }; + if (sourceImageIds.length) { + const firstId = String(sourceImageIds[0]); + payload.referenceBlobs = [{ id: firstId, usage: "general", promptReference: 1 }]; + const frames: Array | null> = [{ localBlobRef: firstId }, null]; + if (sourceImageIds.length > 1) { + const lastId = String(sourceImageIds[1]); + (payload.referenceBlobs as Array>).push({ + id: lastId, + usage: "general", + promptReference: 2, + }); + frames[1] = { localBlobRef: lastId }; + } + payload.referenceFrames = frames; + } return payload; } @@ -860,32 +1020,253 @@ export function buildAdobeSubmitNonce(accessToken: string, prompt: string): stri } /** - * Synthesize x-arp-session-id when no sherlockToken cookie is available. - * Shape matches adobe2api / GPT2Image-Pro: base64(JSON({sid, ftr})). - * Working clients ALWAYS send this header on generate-async. + * Live firefly.adobe.com Arkose public key (web_providers/adobe_atach_images.txt, 2026-07). + * Browser x-arp-session-id is base64(JSON({sid, ark, ftr})) — synthetic sessions without a + * real Arkose blob often get colligo HTTP 408 "system under load". Prefer pasted sherlockToken. */ -export function buildAdobeArpSessionId(): string { +export const ADOBE_FIREFLY_ARKOSE_PUBLIC_KEY = "BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C"; +/** Live ftr magic (replaces older adobe2api `dUAL43-mnts-ants-d4_31ck__tt`). */ +export const ADOBE_FIREFLY_FTR_MAGIC = "__UDF43-m4_31ck"; + +/** + * True when a string looks like a Firefly ARP session (base64 JSON with sid). + */ +export function isValidAdobeArpSessionId(value: string): boolean { + const t = String(value || "").trim(); + if (t.length < 4) return false; + // Never treat Cookie name=value pairs (e.g. aux_sid=…, forter=…) as ARP. + // Live ARP is base64(JSON) or a bare opaque token — not "key=value". + if (/^[A-Za-z_][A-Za-z0-9_.%-]*=/.test(t) && !t.startsWith("eyJ")) return false; + try { + const padded = t + "=".repeat((4 - (t.length % 4)) % 4); + const json = Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString( + "utf8" + ); + // 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; + }; + return typeof obj.sid === "string" && obj.sid.length > 0; + } catch { + // Opaque short sherlockToken values (tests / non-JSON) when non-empty. + // No mid-string "=" (cookie pair leftovers); padding "=" at end is OK. + if (/=.+/.test(t.replace(/=+$/, ""))) return false; + return !looksLikeAdobeJwt(t) && /^[A-Za-z0-9+/_=-]+$/.test(t); + } +} + +/** + * Synthesize x-arp-session-id when no browser sherlockToken is available. + * Shape matches live successful generate (adobe/image_generate.txt): + * base64(JSON({sid, ark, bfp, ftr, fpjs})) + * ALWAYS send this header on generate-async / storage upload. + * Prefer real sherlockToken / cookie rebuild (forter+arkose+sid) when available. + */ +export function buildAdobeArpSessionId(region = "eu-west-1"): string { const nowMs = Date.now(); - const rand = randomBytes(16).toString("hex"); const sid = randomUUID(); - const pid = typeof process !== "undefined" && process.pid ? process.pid : 0; - // Magic suffix is part of the wire contract reverse-engineered by adobe2api. - const ftr = `${rand}_${nowMs}_${pid}_dUAL43-mnts-ants-d4_31ck__tt`; - const raw = JSON.stringify({ sid, ftr }); + const randHex = randomBytes(16).toString("hex"); + // Live ftr: {32hex}_{ms}__UDF43-m4_31ck_{b64}=-N-v2_tt + const mid = randomBytes(12).toString("base64url"); + const n = 1000 + Math.floor(Math.random() * 9000); + const ftr = `${randHex}_${nowMs}${ADOBE_FIREFLY_FTR_MAGIC}_${mid}=-${n}-v2_tt`; + // Arkose session-shaped string (public pk from firefly SPA). Without a real + // Arkose solve this may still 408; real sherlockToken is the stable path. + const arkSession = `${randomBytes(8).toString("hex")}.${Math.random().toFixed(10).slice(2)}`; + const ark = + `${arkSession}|r=${region}|meta=3|metabgclr=transparent|metaiconclr=%23757575|` + + `guitextcolor=%23000000|pk=${ADOBE_FIREFLY_ARKOSE_PUBLIC_KEY}|at=40|sup=1|rid=13|ag=101|` + + `cdn_url=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc|` + + `surl=https%3A%2F%2Farks-client.adobe.com|` + + `smurl=https%3A%2F%2Farks-client.adobe.com%2Fcdn%2Ffc%2Fassets%2Fstyle-manager`; + // Successful browser ARP also carries Browser Fingerprint + FingerprintJS payload. + const bfp = randomUUID(); + const fpjs = JSON.stringify({ + requestId: `${nowMs}.${randomBytes(3).toString("base64url")}`, + visitorId: randomBytes(12).toString("base64url"), + }); + const raw = JSON.stringify({ sid, ark, bfp, ftr, fpjs }); return Buffer.from(raw, "utf-8").toString("base64"); } /** - * Pull sherlockToken / x-arp-session-id from a Cookie header if present. - * Browser generate sends Cookie.sherlockToken as x-arp-session-id. + * Pull sherlockToken / x-arp-session-id from Cookie header, HAR paste, or multi-line credential. + * Browser generate sends Cookie.sherlockToken (or the request header) as x-arp-session-id. + * Live value is base64({sid, ark, ftr}) — includes Arkose session data. + * + * Also handles PasswordBox mangling (JWT + ARP joined by a single space) and full fetch() + * copy/paste from DevTools (web_providers/adobe_atach_images.txt). */ export function extractAdobeArpSessionId(cookieOrBlob: string): string { const raw = String(cookieOrBlob || ""); - const m = raw.match(/(?:^|[;\s])sherlockToken=([^;]+)/i); - if (m?.[1]) return decodeURIComponent(m[1].trim()); - const m2 = raw.match(/(?:^|[;\s])x-arp-session-id=([^;]+)/i); - if (m2?.[1]) return decodeURIComponent(m2[1].trim()); - return ""; + if (!raw.trim()) return ""; + + const candidates: string[] = []; + const push = (v: string | undefined | null) => { + if (!v) return; + 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); + } catch { + /* keep raw */ + } + if (t) candidates.push(t); + }; + + // Cookie: sherlockToken=... + const m = raw.match(/(?:^|[;\s\n\r])sherlockToken=([^;\s\n\r]+)/i); + if (m?.[1]) push(m[1]); + + // Cookie or form: x-arp-session-id=... + const m2 = raw.match(/(?:^|[;\s\n\r])x-arp-session-id=([^;\s\n\r]+)/i); + if (m2?.[1]) push(m2[1]); + + // HAR / Network / fetch() headers: "x-arp-session-id": "eyJ..." or x-arp-session-id: eyJ... + const m3 = raw.match(/["']?x-arp-session-id["']?\s*[:=]\s*["']?([A-Za-z0-9+/=_-]{40,})["']?/i); + if (m3?.[1]) push(m3[1]); + + // HAR: "sherlockToken": "eyJ..." + const m4 = raw.match(/["']?sherlockToken["']?\s*[:=]\s*["']?([A-Za-z0-9+/=_-]{40,})["']?/i); + if (m4?.[1]) push(m4[1]); + + // Bare base64 ARP blob on its own line (line 2 of two-line paste) + for (const line of raw.split(/[\r\n]+/)) { + const t = line.trim().replace(/^["']|["']$/g, ""); + // Skip pure JWT lines + if (looksLikeAdobeJwt(t)) continue; + if (t.length >= 40 && isValidAdobeArpSessionId(t)) push(t); + } + + // JWT + ARP joined by whitespace (single-line PasswordBox paste collapses \n → space) + // Split on whitespace only — NOT on "=" — so we never treat "aux_sid=…" as a token. + const withoutJwt = raw.replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, " "); + for (const token of withoutJwt.split(/[\s,;"']+/)) { + let t = token.trim(); + // If this chunk is name=value from a Cookie header, only keep the value when + // the name is sherlockToken / x-arp-session-id; skip aux_sid, forter, etc. + const eq = t.indexOf("="); + if (eq > 0 && eq < 40 && /^[A-Za-z0-9_.%-]+$/.test(t.slice(0, eq))) { + const name = t.slice(0, eq).toLowerCase(); + if (name === "sherlocktoken" || name === "x-arp-session-id") { + t = t.slice(eq + 1).trim(); + } else { + continue; + } + } + if (t.length >= 40 && isValidAdobeArpSessionId(t)) push(t); + } + + // Prefer ARP that decodes to JSON with sid+ark (real browser session over opaque short tokens) + const ranked = candidates + .map((c) => c.replace(/^["']|["']$/g, "").trim()) + .filter((v) => isValidAdobeArpSessionId(v)); + ranked.sort((a, b) => scoreAdobeArpCandidate(b) - scoreAdobeArpCandidate(a)); + return ranked[0] || ""; +} + +/** Higher = more like a live firefly-3p x-arp-session-id (sid+ark+ftr[+bfp+fpjs] base64). */ +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 obj = JSON.parse(json) as { + sid?: unknown; + ark?: unknown; + ftr?: unknown; + bfp?: unknown; + fpjs?: unknown; + }; + 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; + // 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; + } catch { + /* opaque sherlockToken */ + } + return score; +} + +/** + * True when the credential blob already contains a browser ARP / sherlockToken + * OR enough cookie pieces to rebuild one (ff_session_guid + arkose + forterToken). + * Synthetic-only ARP is a fallback — real cookie pieces are required for stable generate. + */ +export function hasBrowserAdobeArpSession(sessionCookieOrBlob?: string): boolean { + const blob = String(sessionCookieOrBlob || ""); + if (extractAdobeArpSessionId(blob)) return true; + // Rebuild path counts as browser ARP (same pieces the SPA uses for sherlockToken). + const sid = blob.match(/(?:^|[;\s])ff_session_guid=([^;\s]+)/i)?.[1]; + const ark = blob.match(/(?:^|[;\s])arkose=([^;\s]+)/i)?.[1]; + const ftr = + blob.match(/(?:^|[;\s])forterToken=([^;\s]+)/i)?.[1] || + blob.match(/(?:^|[;\s])forter=([^;\s]+)/i)?.[1]; + return Boolean(sid && ark && ftr && !/^[a-f0-9]{32},\d+$/i.test(ftr)); +} + +/** + * Resolve ARP for a Firefly request. + * Prefer cookie rebuild (ff_session_guid + arkose + forterToken [+bfp/fpjs]) over a + * frozen sherlockToken paste — Forter advances while the pasted ARP goes stale. + * Fall back to sherlockToken / x-arp-session-id extract, then synthetic rich ARP. + * Mint once per generate/upload chain and reuse (browser uses the same ARP for upload+submit); + * on 408 the submit loop rotates ARP separately. + */ +export function resolveAdobeArpSessionId(sessionCookieOrBlob?: string): string { + const blob = String(sessionCookieOrBlob || ""); + // 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")); + if (!m?.[1]) return ""; + let v = m[1].trim(); + try { + if (/%[0-9A-Fa-f]{2}/.test(v)) v = decodeURIComponent(v); + } catch { + /* keep */ + } + return v; + }; + const sid = getCookie("ff_session_guid"); + const ark = getCookie("arkose"); + let ftr = getCookie("forterToken") || getCookie("forter"); + try { + if (/%[0-9A-Fa-f]{2}/.test(ftr)) ftr = decodeURIComponent(ftr); + } catch { + /* keep */ + } + if (ftr.endsWith("v2") && !ftr.endsWith("v2_tt")) ftr = `${ftr}_tt`; + // Skip localStorage-style "id,timestamp" forter values + if (/^[a-f0-9]{32},\d+$/i.test(ftr)) ftr = ""; + if (sid && ark && ftr) { + const bfp = getCookie("bfp"); + let fpjs = getCookie("fpjs"); + try { + if (fpjs && /%[0-9A-Fa-f]{2}/.test(fpjs)) fpjs = decodeURIComponent(fpjs); + } catch { + /* keep */ + } + const obj: Record = { sid, ark, ftr }; + if (bfp) obj.bfp = bfp; + if (fpjs) obj.fpjs = fpjs; + return Buffer.from(JSON.stringify(obj), "utf-8").toString("base64"); + } + const extracted = extractAdobeArpSessionId(blob); + if (extracted) return extracted; + return buildAdobeArpSessionId(); } export function buildAdobeSubmitHeaders( @@ -898,17 +1279,18 @@ export function buildAdobeSubmitHeaders( prompt?: string; } ): Record { - // Live capture + working open-source clients (GPT2Image-Pro / adobe2api): - // Authorization + x-api-key + deterministic x-nonce + ALWAYS x-arp-session-id. - // Do NOT attach firefly.adobe.com page Cookie to firefly-3p (wrong origin / soft 408). - void extras?.cookie; + // Live capture (web_providers/adobe_atach_images.txt) + working clients: + // Authorization + x-api-key + x-nonce + ALWAYS x-arp-session-id (sid+ark+ftr). + // Do NOT attach firefly.adobe.com page Cookie to firefly-3p (wrong origin). + // Prefer real sherlockToken from cookie blob; synthetic ARP is fallback only. + const cookieBlob = String(extras?.cookie || "").trim(); const deterministic = extras?.nonce || (extras?.prompt ? buildAdobeSubmitNonce(accessToken, extras.prompt) : "") || generateAdobeNonce(); - // Prefer pasted sherlockToken; otherwise mint a synthetic ARP session (required). - const arp = - (extras?.arpSessionId && String(extras.arpSessionId).trim()) || buildAdobeArpSessionId(); + // 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 headers: Record = { ...browserHeaders(), Authorization: `Bearer ${accessToken}`, @@ -1050,49 +1432,6 @@ export function extractAdobeSourceImageSources(body: unknown, max = 4): string[] return out.slice(0, max); } -export interface AdobeFireflySourceImageReference { - source: string; - usage?: string; - order?: number; -} - -/** Structured extension used when a caller needs style/element/frame semantics. */ -export function extractAdobeSourceImageReferences( - body: unknown, - max = 4 -): AdobeFireflySourceImageReference[] { - const record = body && typeof body === "object" ? (body as Record) : {}; - const explicit = record.adobe_reference_inputs ?? record.adobeReferenceInputs; - if (Array.isArray(explicit)) { - const references: AdobeFireflySourceImageReference[] = []; - for (const value of explicit) { - if (references.length >= max) break; - if (!value || typeof value !== "object") continue; - const item = value as Record; - const mediaType = String(item.media_type ?? item.mediaType ?? "image").toLowerCase(); - if (mediaType !== "image") continue; - const source = - typeof item.source === "string" - ? item.source - : typeof item.url === "string" - ? item.url - : typeof item.image_url === "string" - ? item.image_url - : ""; - if (!source.trim()) continue; - const usage = String(item.usage ?? item.usage_type ?? item.usageType ?? "").trim(); - const orderValue = Number(item.order); - references.push({ - source: source.trim(), - ...(usage ? { usage } : {}), - ...(Number.isInteger(orderValue) && orderValue > 0 ? { order: orderValue } : {}), - }); - } - return references; - } - return extractAdobeSourceImageSources(body, max).map((source) => ({ source })); -} - export function parseAdobeImageSourceBytes(source: string): { buffer: Buffer; contentType: string; @@ -1125,7 +1464,10 @@ 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 @@ -1175,10 +1517,15 @@ export async function uploadAdobeFireflyImage(opts: { bytes: Buffer | Uint8Array; contentType?: string; sessionCookie?: string; + /** Reuse the same ARP as generate-async (browser does). */ + arpSessionId?: string; /** 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); @@ -1195,8 +1542,10 @@ export async function uploadAdobeFireflyImage(opts: { const sessionCookie = String(opts.sessionCookie || "").trim(); const cookieHeader = extractAdobeCookieHeader(sessionCookie); + // One ARP for the whole chain — do not mint a new synthetic id per upload. const arpSessionId = - extractAdobeArpSessionId(cookieHeader) || extractAdobeArpSessionId(sessionCookie); + (opts.arpSessionId && String(opts.arpSessionId).trim()) || + resolveAdobeArpSessionId(cookieHeader || sessionCookie); const contentType = (opts.contentType && opts.contentType.trim()) || (buffer[0] === 0xff && buffer[1] === 0xd8 @@ -1208,11 +1557,11 @@ export async function uploadAdobeFireflyImage(opts: { const resp = await fetchImpl(ADOBE_FIREFLY_IMAGE_UPLOAD_URL, { method: "POST", headers: buildAdobeUploadHeaders(opts.accessToken, contentType, { - arpSessionId: arpSessionId || undefined, + arpSessionId, cookie: cookieHeader || undefined, prompt: opts.prompt || "upload", }), - body: buffer as unknown as BodyInit, + body: Uint8Array.from(buffer), }); const text = await resp.text().catch(() => ""); @@ -1260,16 +1609,25 @@ export async function resolveAdobeSourceImageIds(opts: { body: unknown; max?: number; sessionCookie?: string; + /** Shared ARP for upload+generate (required for stable Firefly 3P). */ + 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(32, opts.max ?? 4)); + const max = Math.max(1, Math.min(8, opts.max ?? 4)); const sources = extractAdobeSourceImageSources(opts.body, max); if (!sources.length) return []; const fetchImpl = opts.fetchImpl || fetch; const ids: string[] = []; + // One ARP for all uploads in this request (browser reuses the same header). + const arpSessionId = + (opts.arpSessionId && String(opts.arpSessionId).trim()) || + resolveAdobeArpSessionId(opts.sessionCookie); for (const src of sources) { // Already a Firefly storage id (uuid) @@ -1310,6 +1668,7 @@ export async function resolveAdobeSourceImageIds(opts: { bytes: buffer, contentType, sessionCookie: opts.sessionCookie, + arpSessionId, prompt: opts.prompt, fetchImpl, log: opts.log, @@ -1320,31 +1679,6 @@ export async function resolveAdobeSourceImageIds(opts: { return ids; } -export async function resolveAdobeSourceImageReferences(opts: { - accessToken: string; - body: unknown; - max?: number; - sessionCookie?: string; - prompt?: string; - fetchImpl?: typeof fetch; - log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; -}): Promise { - const max = Math.max(1, Math.min(32, opts.max ?? 4)); - const references = extractAdobeSourceImageReferences(opts.body, max); - if (references.length === 0) return []; - const ids = await resolveAdobeSourceImageIds({ - ...opts, - max, - body: { images: references.map((reference) => reference.source) }, - }); - return ids.map((id, index) => ({ - id, - mediaType: "image", - ...(references[index]?.usage ? { usage: references[index].usage } : {}), - ...(references[index]?.order ? { order: references[index].order } : {}), - })); -} - /** Transient Adobe 3P overload / rate / edge errors worth retrying. */ export function isAdobeTransientSubmitError(status: number, bodyText: string): boolean { if (status === 408 || status === 429 || status === 502 || status === 503 || status === 504) { @@ -1397,13 +1731,29 @@ export function buildAdobeDiscoveryHeaders(accessToken: string): Record { const form = new URLSearchParams({ client_id: opts.clientId, @@ -1595,7 +1944,7 @@ async function imsCheckToken(opts: { if (!resp.ok) { return { - state: "failed", + ok: false, status: resp.status, error: sanitizeErrorMessage( data?.error_description || data?.error || text.slice(0, 200) || `HTTP ${resp.status}` @@ -1606,14 +1955,14 @@ async function imsCheckToken(opts: { const token = String(data?.access_token || "").trim(); if (!token) { return { - state: "failed", + ok: false, status: 401, error: sanitizeErrorMessage( data?.error_description || data?.error || "IMS response missing access_token" ), }; } - return { state: "ok", token, data: data || {} }; + return { ok: true, token, data: data || {} }; } /** @@ -1660,7 +2009,7 @@ export async function exchangeAdobeCookieForAccessToken( guestAllowed: false, fetchImpl, }); - if (authed.state === "ok") { + if (authed.ok) { if ( isAdobeGuestAccessToken(authed.token) || authed.data.account_type === "guest" || @@ -1683,7 +2032,7 @@ export async function exchangeAdobeCookieForAccessToken( guestAllowed: true, fetchImpl, }); - if (guest.state === "ok") { + if (guest.ok) { if ( guest.data.account_type === "guest" || guest.data.guestId || @@ -1794,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 && @@ -1882,10 +2235,60 @@ export async function fetchAdobeCreditsBalance( // ── Models discovery ──────────────────────────────────────────────────────── +export interface AdobeFireflyDiscoveredModel { + modelId: string; + modelVersion: string; + displayName: string; + modality: "image" | "video" | "audio" | "unknown"; + enabled: boolean; + healthStatus?: string; +} + +/** + * Parse POST /v2/models/discovery response into flat model/version rows. + */ +export function parseAdobeModelsDiscovery(body: unknown): AdobeFireflyDiscoveredModel[] { + const root = body && typeof body === "object" ? (body as Record) : {}; + const models = Array.isArray(root.models) ? root.models : []; + const out: AdobeFireflyDiscoveredModel[] = []; + + for (const m of models) { + if (!m || typeof m !== "object") continue; + const rec = m as Record; + const modelId = String(rec.modelId || "").trim(); + if (!modelId) continue; + const versions = + rec.modelVersions && typeof rec.modelVersions === "object" + ? (rec.modelVersions as Record) + : {}; + for (const [ver, spec] of Object.entries(versions)) { + if (!spec || typeof spec !== "object") continue; + const s = spec as Record; + if (s.enabled === false) continue; + const mods = Array.isArray(s.outputModality) + ? s.outputModality.map((x) => String(x).toLowerCase()) + : []; + let modality: AdobeFireflyDiscoveredModel["modality"] = "unknown"; + if (mods.includes("image")) modality = "image"; + else if (mods.includes("video")) modality = "video"; + else if (mods.includes("audio")) modality = "audio"; + out.push({ + modelId, + modelVersion: ver, + displayName: String(s.modelDisplayName || s.modelCaiDisplayName || ver), + modality, + enabled: s.enabled !== false, + healthStatus: typeof s.healthStatus === "string" ? s.healthStatus : undefined, + }); + } + } + return out; +} + export async function discoverAdobeFireflyModels( accessToken: string, fetchImpl: typeof fetch = fetch -) { +): Promise { const resp = await fetchImpl(ADOBE_FIREFLY_MODELS_DISCOVERY_URL, { method: "POST", headers: buildAdobeDiscoveryHeaders(accessToken), @@ -1913,14 +2316,20 @@ async function sleep(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } -export async function pollAdobeJob(opts: { +async function pollAdobeJob(opts: { pollUrl: string; accessToken: string; 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; 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; @@ -1928,12 +2337,14 @@ export 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) { @@ -1945,6 +2356,44 @@ export 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"); } @@ -1998,11 +2447,23 @@ export async function pollAdobeJob(opts: { throw new AdobeFireflyError(`Adobe Firefly ${opts.kind} generation timed out`, 504, "timeout"); } -// Colligo often returns instant 408 with x-colligo-timeout:0.0 under load. -// Keep retries short: hammering Adobe with 8 long waits makes the Media page -// look broken while balance still works. SPA succeeds on a healthy queue/token. -const SUBMIT_MAX_ATTEMPTS = 4; -const SUBMIT_BASE_DELAY_MS = 1200; +// Colligo often returns instant 408 with x-colligo-timeout:0.0 under load OR when +// generate-async is hammered in a batch. Space submits (gate) + reuse sticky ARP; +// do NOT thrash synthetic rebuilds on every retry (identical forter → no-op). +// More attempts: 1–2 reuse sticky ARP when forter is fresh; stale forter / attempt 3+ → off-screen Chrome warm. +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 !== "" + ) { + 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; + return 8000; +} export async function adobeFireflyGenerateImage(opts: { accessToken: string; @@ -2013,13 +2474,21 @@ export async function adobeFireflyGenerateImage(opts: { quality?: unknown; seed?: number; sourceImageIds?: string[]; - references?: AdobeFireflyReferenceBlob[]; negativePrompt?: string; /** Optional Cookie blob — used only to lift sherlockToken → x-arp-session-id */ sessionCookie?: string; + /** Shared ARP (sid+ark+ftr). Reuse with uploads; do not mint per retry. */ + 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?: { 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); @@ -2033,33 +2502,58 @@ export async function adobeFireflyGenerateImage(opts: { quality: opts.quality, seed: opts.seed, sourceImageIds: opts.sourceImageIds, - references: opts.references, negativePrompt: opts.negativePrompt, }); const sessionCookie = String(opts.sessionCookie || "").trim(); - const cookieHeader = extractAdobeCookieHeader(sessionCookie); - // Prefer real browser sherlockToken; buildAdobeSubmitHeaders mints synthetic ARP if empty. - const arpSessionId = - extractAdobeArpSessionId(cookieHeader) || extractAdobeArpSessionId(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(activeCookie); + let arpSessionId = + (opts.arpSessionId && String(opts.arpSessionId).trim()) || + 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, + markAdobeFireflyArpSuccess, + noteAdobeFireflySubmitFailure, + 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, activeCookie].filter(Boolean).join("\n")); + const browserSessionKey = String(opts.sessionBrowserKey || "").trim() || fingerprint; + + // 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++) { - // Deterministic x-nonce from user_id+prompt (adobe2api/GPT2Image-Pro). Fresh ARP each attempt. - const submitResp = await fetchImpl(ADOBE_FIREFLY_IMAGE_SUBMIT_URL, { - method: "POST", - headers: buildAdobeSubmitHeaders(opts.accessToken, { - arpSessionId: arpSessionId || undefined, - prompt: opts.prompt, - cookie: cookieHeader || undefined, - }), - body: JSON.stringify(payload), - }); + const submitResp = await withAdobeFireflySubmitGate(() => + fetchImpl(ADOBE_FIREFLY_IMAGE_SUBMIT_URL, { + method: "POST", + headers: buildAdobeSubmitHeaders(accessToken, { + arpSessionId, + prompt: opts.prompt, + 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( @@ -2068,8 +2562,35 @@ 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, + 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 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), not page cookies alone.", + "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" ); @@ -2082,20 +2603,73 @@ 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) { - // Exponential backoff: 2s, 4s, 8s, 16s… capped at 45s (+ jitter) + 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 }) + + " 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 = - Math.min(45_000, SUBMIT_BASE_DELAY_MS * Math.pow(2, attempt - 1)) + - Math.floor(Math.random() * 750); + 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` + `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( - formatAdobeSystemUnderLoadError("image", attempt), + formatAdobeSystemUnderLoadError("image", attempt, { + hadBrowserArp, + }), 408, "system_under_load" ); @@ -2108,14 +2682,26 @@ export async function adobeFireflyGenerateImage(opts: { 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) { if (sawSystemUnderLoad) { throw new AdobeFireflyError( - formatAdobeSystemUnderLoadError("image", SUBMIT_MAX_ATTEMPTS), + formatAdobeSystemUnderLoadError("image", SUBMIT_MAX_ATTEMPTS, { + hadBrowserArp, + }), 408, "system_under_load" ); @@ -2127,15 +2713,11 @@ export async function adobeFireflyGenerateImage(opts: { } pollUrl = normalizeAdobePollUrl(pollUrl); - const pollTimeoutMs = adobeFireflyImageTimeoutMs({ - timeoutMs: opts.timeoutMs, - refCount: opts.references?.length ?? opts.sourceImageIds?.length ?? 0, - }); const { mediaUrl, latest } = await pollAdobeJob({ pollUrl, - accessToken: opts.accessToken, + accessToken, kind: "image", - timeoutMs: pollTimeoutMs, + timeoutMs: opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_IMAGE_TIMEOUT_MS, fetchImpl, log: opts.log, }); @@ -2154,14 +2736,27 @@ export async function adobeFireflyGenerateVideo(opts: { resolution?: unknown; seed?: number; sourceImageIds?: string[]; - references?: AdobeFireflyReferenceBlob[]; negativePrompt?: string; generateAudio?: boolean; sessionCookie?: string; + /** Shared ARP (sid+ark+ftr). Reuse with frame uploads. */ + 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?: { 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"); @@ -2186,32 +2781,54 @@ export async function adobeFireflyGenerateVideo(opts: { resolution, seed: opts.seed, sourceImageIds: opts.sourceImageIds, - references: opts.references, negativePrompt: opts.negativePrompt, generateAudio: opts.generateAudio, }); const sessionCookie = String(opts.sessionCookie || "").trim(); - const cookieHeader = extractAdobeCookieHeader(sessionCookie); - const arpSessionId = - extractAdobeArpSessionId(cookieHeader) || extractAdobeArpSessionId(sessionCookie); + let activeCookie = extractAdobeCookieHeader(sessionCookie) || sessionCookie; + const hadBrowserArp = hasBrowserAdobeArpSession(activeCookie); + let arpSessionId = + (opts.arpSessionId && String(opts.arpSessionId).trim()) || + 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, + markAdobeFireflyArpSuccess, + noteAdobeFireflySubmitFailure, + rotateAdobeFireflySessionOnError, + resolveAdobeArpSessionIdSmart, + fingerprintAdobeCredential, + estimateAdobeTokenExpiry, + } = await import("./adobeFireflySession.ts"); + + const fingerprint = + String(opts.sessionFingerprint || "").trim() || + fingerprintAdobeCredential([accessToken, activeCookie].filter(Boolean).join("\n")); + const browserSessionKey = String(opts.sessionBrowserKey || "").trim() || fingerprint; + + let videoSubmitOk = false; for (let attempt = 1; attempt <= SUBMIT_MAX_ATTEMPTS; attempt++) { - const submitResp = await fetchImpl(ADOBE_FIREFLY_VIDEO_SUBMIT_URL, { - method: "POST", - headers: buildAdobeSubmitHeaders(opts.accessToken, { - arpSessionId: arpSessionId || undefined, - prompt: opts.prompt, - cookie: cookieHeader || undefined, - }), - body: JSON.stringify(payload), - }); + const submitResp = await withAdobeFireflySubmitGate(() => + fetchImpl(ADOBE_FIREFLY_VIDEO_SUBMIT_URL, { + method: "POST", + headers: buildAdobeSubmitHeaders(accessToken, { + arpSessionId, + prompt: opts.prompt, + 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( @@ -2220,8 +2837,35 @@ 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, + 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 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), not page cookies alone.", + "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" ); @@ -2234,19 +2878,69 @@ 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) { + 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 }) + + " 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 = - Math.min(45_000, SUBMIT_BASE_DELAY_MS * Math.pow(2, attempt - 1)) + - Math.floor(Math.random() * 750); + 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` + `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( - formatAdobeSystemUnderLoadError("video", attempt), + formatAdobeSystemUnderLoadError("video", attempt, { + hadBrowserArp, + }), 408, "system_under_load" ); @@ -2259,14 +2953,25 @@ export async function adobeFireflyGenerateVideo(opts: { 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) { if (sawSystemUnderLoad) { throw new AdobeFireflyError( - formatAdobeSystemUnderLoadError("video", SUBMIT_MAX_ATTEMPTS), + formatAdobeSystemUnderLoadError("video", SUBMIT_MAX_ATTEMPTS, { + hadBrowserArp, + }), 408, "system_under_load" ); @@ -2280,9 +2985,11 @@ export async function adobeFireflyGenerateVideo(opts: { const { mediaUrl, latest } = await pollAdobeJob({ pollUrl, - accessToken: opts.accessToken, + 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 new file mode 100644 index 0000000000..d8ab034993 --- /dev/null +++ b/open-sse/services/adobeFireflySession.ts @@ -0,0 +1,1002 @@ +/** + * Adobe Firefly durable session manager. + * + * Goal: same as other OmniRoute web-cookie providers (notion-web, perplexity-web): + * paste Cookie (+ optional IMS JWT) once and use pure HTTP — **no browser window**. + * + * 1) Extract / cache IMS user JWT from paste (or short-lived memory/disk cache) + * 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) 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"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + AdobeFireflyError, + buildAdobeArpSessionId, + extractAdobeArpSessionId, + extractAdobeCookieHeader, + extractAdobeCredentialToken, + isAdobeUserAccessToken, + looksLikeAdobeCookieBlob, + looksLikeAdobeJwt, + decodeAdobeJwtPayload, + resolveAdobeAccessToken, + exchangeAdobeCookieForAccessToken, +} from "./adobeFireflyClient.ts"; + +export interface AdobeFireflySession { + accessToken: string; + cookie: string; + arpSessionId: string; + /** Epoch ms when the IMS token is expected to expire (best-effort). */ + tokenExpiresAt: number; + updatedAt: number; + /** Hash of the original credential paste (cache key). */ + fingerprint: string; + /** Stable provider connection id used to isolate browser SSO/cookie state per Adobe account. */ + browserSessionKey?: string; + source: "paste" | "ims" | "browser" | "cache" | "rebuild"; +} + +export interface AdobeFireflySessionResolveOpts { + credentials?: { + apiKey?: string; + accessToken?: string; + connectionId?: string; + providerSpecificData?: { + cookie?: unknown; + access_token?: unknown; + accessToken?: unknown; + browserSessionKey?: 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 durable CDP refresh (tests / hosts without Chrome or Edge). */ + allowBrowserRefresh?: boolean; +} + +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; + +/** Do not thrash rebuilds: a working ARP stays sticky for this long unless 408 clears it. */ +const WORKING_ARP_STICKY_MS = 25 * 60_000; +/** Forter token age above this → consider risk session stale (informational / recovery). */ +const FORTER_STALE_MS = 4 * 60_000; +/** After this many successful submits in a row, add an extra quiet period (colligo batch throttle). */ +const BATCH_SUCCESS_COOLDOWN_EVERY = 3; +const BATCH_SUCCESS_EXTRA_GAP_MS = 15_000; + +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 !== "" + ) { + 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; + // 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 + ) { + return Number(process.env.ADOBE_FIREFLY_BATCH_EXTRA_GAP_MS || BATCH_SUCCESS_EXTRA_GAP_MS); + } + return 0; +} +/** Refresh IMS token this many ms before JWT expiry. */ +const JWT_REFRESH_SKEW_MS = 10 * 60_000; +/** + * Proactively browser-warm the risk session when the Forter token is older than this. + * Colligo 408s a stale Forter/ARP; warming before the first submit avoids the wasted 408. + * Kept above a single batch's duration so mid-batch requests reuse the sticky working ARP. + */ +const FORTER_PROACTIVE_WARM_MS = 3 * 60_000; + +/** + * Browser Forter-warm is the DEFAULT engine for Adobe Firefly (the only reliable way to + * keep the Forter/Arkose risk session fresh — pure HTTP goes stale and 408s). It stays on + * unless explicitly disabled with ADOBE_FIREFLY_BROWSER_REFRESH=0. The legacy opt-in value + * "1" still enables it; any other value (including unset) now also enables it. + */ +export function adobeFireflyBrowserEnabled(): boolean { + return process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "0"; +} +/** Persist sessions under DATA_DIR so restarts keep JWT + last cookie. */ +const SESSION_DIR_NAME = "adobe-firefly-sessions"; + +function dataDir(): string { + return ( + String(process.env.DATA_DIR || process.env.OMNIROUTE_DATA_DIR || "").trim() || + join(process.cwd(), ".data") + ); +} + +function sessionFilePath(fingerprint: string): string { + const dir = join(dataDir(), SESSION_DIR_NAME); + try { + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + } catch { + /* ignore */ + } + return join(dir, `${fingerprint}.json`); +} + +export function fingerprintAdobeCredential(raw: string): string { + 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 m = raw.match(re); + if (!m?.[1]) return ""; + let v = m[1].trim().replace(/^["']|["']$/g, ""); + try { + if (/%[0-9A-Fa-f]{2}/.test(v)) v = decodeURIComponent(v); + } catch { + /* keep */ + } + return v; +} + +/** Normalize Forter token to the live ftr shape ending in -v2_tt. */ +export function normalizeAdobeForterToken(value: string): string { + let f = String(value || "").trim(); + if (!f) return ""; + try { + if (/%[0-9A-Fa-f]{2}/.test(f)) f = decodeURIComponent(f); + } catch { + /* keep */ + } + // Cookie sometimes stores "id,timestamp" (localStorage form) — not usable as ftr. + if (/^[a-f0-9]{32},\d+$/i.test(f)) return ""; + if (f.endsWith("v2") && !f.endsWith("v2_tt")) f = `${f}_tt`; + return f; +} + +/** Epoch ms embedded in forterToken (`…_{ms}__UDF43…`), or 0 if unknown. */ +export function extractAdobeForterTimestampMs(cookieOrBlob: string): number { + const ftr = + normalizeAdobeForterToken(getAdobeCookieValue(cookieOrBlob, "forterToken")) || + normalizeAdobeForterToken(getAdobeCookieValue(cookieOrBlob, "forter")) || + ""; + const m = ftr.match(/_(\d{13})__/); + return m ? Number(m[1]) : 0; +} + +export function getAdobeForterAgeMs(cookieOrBlob: string): number { + const ts = extractAdobeForterTimestampMs(cookieOrBlob); + if (!ts) return Number.POSITIVE_INFINITY; + return Math.max(0, Date.now() - ts); +} + +/** Remember an ARP that just got generate-async 2xx — batch jobs must stick to it. */ +export function markAdobeFireflyArpSuccess(fingerprint: string, arpSessionId: string): void { + const fp = String(fingerprint || "").trim(); + const arp = String(arpSessionId || "").trim(); + if (!fp || !arp) return; + lastWorkingArpByFingerprint.set(fp, { arp, at: Date.now() }); + consecutiveAdobeSubmitSuccesses += 1; + const cached = sessionCache.get(fp); + if (cached) { + cached.arpSessionId = arp; + cached.updatedAt = Date.now(); + sessionCache.set(fp, cached); + saveDiskSession(cached); + } else { + // Persist sticky ARP even when session map was not primed (fingerprint-only mark). + try { + const path = sessionFilePath(fp); + if (existsSync(path)) { + const obj = JSON.parse(readFileSync(path, "utf8")) as AdobeFireflySession; + obj.arpSessionId = arp; + obj.updatedAt = Date.now(); + writeFileSync(path, JSON.stringify(obj, null, 2), "utf8"); + sessionCache.set(fp, { ...obj, fingerprint: fp }); + } + } catch { + /* best-effort */ + } + } +} + +export function clearAdobeFireflyWorkingArp(fingerprint: string): void { + lastWorkingArpByFingerprint.delete(String(fingerprint || "").trim()); +} + +export function noteAdobeFireflySubmitFailure(): void { + consecutiveAdobeSubmitSuccesses = 0; +} + +/** + * Serialize Firefly generate-async calls and enforce a quiet period. + * Colligo often returns 408 "system under load" when submits are hammered in a batch + * or after a few successes in a row with the same risk session. + */ +export async function withAdobeFireflySubmitGate(fn: () => Promise): Promise { + const run = adobeSubmitChain.then(async () => { + const gap = minSubmitGapMs() + batchExtraGapMs(); + const wait = Math.max(0, lastAdobeSubmitAt + gap - Date.now()); + if (wait > 0) { + await new Promise((r) => setTimeout(r, wait)); + } + try { + return await fn(); + } finally { + lastAdobeSubmitAt = Date.now(); + } + }); + // Keep the chain alive even if fn throws + adobeSubmitChain = run.then( + () => undefined, + () => undefined + ); + return run; +} + +/** + * Rebuild x-arp-session-id from browser cookie components. + * Live successful generate-async ARP is base64(JSON({sid, ark, ftr, bfp?, fpjs?})). + * Returns "" when required pieces are missing. + */ +export function buildAdobeArpSessionIdFromCookies( + cookieOrBlob: string, + extras?: { region?: string; bfp?: string; fpjs?: string } +): string { + const blob = String(cookieOrBlob || ""); + if (!blob.trim()) return ""; + + const sid = + getAdobeCookieValue(blob, "ff_session_guid") || getAdobeCookieValue(blob, "sid") || ""; + const ark = getAdobeCookieValue(blob, "arkose") || ""; + const ftr = + normalizeAdobeForterToken(getAdobeCookieValue(blob, "forterToken")) || + normalizeAdobeForterToken(getAdobeCookieValue(blob, "forter")) || + ""; + if (!sid || !ark || !ftr) return ""; + + let bfp = extras?.bfp || getAdobeCookieValue(blob, "bfp") || ""; + let fpjsRaw = extras?.fpjs || getAdobeCookieValue(blob, "fpjs") || ""; + if (fpjsRaw) { + try { + if (/%[0-9A-Fa-f]{2}/.test(fpjsRaw)) fpjsRaw = decodeURIComponent(fpjsRaw); + } catch { + /* keep */ + } + } + + // Prefer rebuilding over a stale sherlockToken when cookie pieces exist — + // forterToken timestamps advance as the SPA warms risk SDKs. + const obj: Record = { sid, ark, ftr }; + if (bfp) obj.bfp = bfp; + if (fpjsRaw) obj.fpjs = fpjsRaw; + return Buffer.from(JSON.stringify(obj), "utf-8").toString("base64"); +} + +/** True when the blob can rebuild a full ARP without a pasted sherlockToken. */ +export function canRebuildAdobeArpFromCookies(cookieOrBlob: string): boolean { + return Boolean(buildAdobeArpSessionIdFromCookies(cookieOrBlob)); +} + +/** + * Resolve the best ARP for a request: + * 1) force-rotate → mint fresh synthetic (or rebuild if cookies present) + * 2) rebuild from cookie pieces (forter/arkose/sid) — usually fresher than sherlock + * 3) explicit sherlockToken / x-arp-session-id from paste + * 4) synthetic rich ARP + */ +export function resolveAdobeArpSessionIdSmart( + cookieOrBlob?: string, + opts?: { rotate?: boolean } +): string { + const blob = String(cookieOrBlob || ""); + if (opts?.rotate) { + const rebuilt = buildAdobeArpSessionIdFromCookies(blob); + if (rebuilt) return rebuilt; + return buildAdobeArpSessionId(); + } + const rebuilt = buildAdobeArpSessionIdFromCookies(blob); + const extracted = extractAdobeArpSessionId(blob); + // Prefer rebuild when both exist: cookie forter is updated by the SPA more often + // than the frozen sherlockToken the user pasted minutes ago. + 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 }; + return String(j.ftr || ""); + } catch { + return ""; + } + })(); + const extractedFtr = (() => { + try { + 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 ""; + } + })(); + // Prefer the ARP whose forter timestamp is newer (…_ms__UDF43…). + const ts = (ftr: string) => { + const m = ftr.match(/_(\d{13})__/); + return m ? Number(m[1]) : 0; + }; + if (ts(rebuiltFtr) >= ts(extractedFtr)) return rebuilt; + return extracted; + } + if (rebuilt) return rebuilt; + if (extracted) return extracted; + return buildAdobeArpSessionId(); +} + +/** Merge cookie name=value pairs (new wins). Single-line Cookie header. */ +export function mergeAdobeCookieHeaders(base: string, updates: string): string { + const map = new Map(); + const ingest = (raw: string) => { + for (const part of String(raw || "").split(";")) { + const idx = part.indexOf("="); + if (idx <= 0) continue; + let name = part.slice(0, idx).trim(); + let value = part.slice(idx + 1).trim(); + if (!name) continue; + try { + name = decodeURIComponent(name); + } catch { + /* keep */ + } + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (/[\r\n\0]/.test(value)) continue; + map.set(name, value); + } + }; + ingest(extractAdobeCookieHeader(base) || base); + ingest(extractAdobeCookieHeader(updates) || updates); + return [...map.entries()].map(([k, v]) => `${k}=${v}`).join("; "); +} + +/** Serialize session back into a multi-line credential paste (JWT + Cookie). */ +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()); + if (session.cookie) lines.push(session.cookie.trim()); + return lines.join("\n"); +} + +export function estimateAdobeTokenExpiry(accessToken: string): number { + const payload = decodeAdobeJwtPayload(accessToken); + if (!payload) return Date.now() + 60 * 60_000; + const created = Number(payload.created_at || 0); + const expiresIn = Number(payload.expires_in || 0); + if (created > 0 && expiresIn > 0) return created + expiresIn; + // Fallback: treat as 20h from now if claims missing + return Date.now() + 20 * 60 * 60_000; +} + +function diskSessionsEnabled(): boolean { + // Unit tests and explicit opt-out skip durable disk cache (avoids sticky IMS skips). + if (process.env.ADOBE_FIREFLY_SESSION_DISK === "0") return false; + if (process.env.NODE_ENV === "test") return false; + if (process.env.VITEST || process.env.NODE_TEST_CONTEXT) return false; + return true; +} + +function loadDiskSession(fingerprint: string): AdobeFireflySession | null { + if (!diskSessionsEnabled()) return null; + try { + const path = sessionFilePath(fingerprint); + if (!existsSync(path)) return null; + const raw = readFileSync(path, "utf8"); + const obj = JSON.parse(raw) as AdobeFireflySession; + if (!obj?.accessToken || !isAdobeUserAccessToken(obj.accessToken)) return null; + return { ...obj, fingerprint, source: "cache" }; + } catch { + return null; + } +} + +function saveDiskSession(session: AdobeFireflySession): void { + if (!diskSessionsEnabled()) return; + try { + const path = sessionFilePath(session.fingerprint); + writeFileSync(path, JSON.stringify(session, null, 2), "utf8"); + } catch { + /* best-effort */ + } +} + +function collectCredentialBlobs( + credentials: AdobeFireflySessionResolveOpts["credentials"] +): string[] { + const out: string[] = []; + const push = (v: unknown) => { + if (typeof v === "string" && v.trim()) out.push(v.trim()); + }; + push(credentials?.apiKey); + push(credentials?.accessToken); + push(credentials?.providerSpecificData?.cookie); + push(credentials?.providerSpecificData?.access_token); + push(credentials?.providerSpecificData?.accessToken); + return out; +} + +/** + * 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. + */ +/** + * 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"], + opts?: { force?: boolean; proveWithPing?: boolean } +): Promise { + const force = opts?.force === true; + // 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 || session.fingerprint, + }); + 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 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: nextToken, + cookie: nextCookie, + arpSessionId: nextArp, + tokenExpiresAt: estimateAdobeTokenExpiry(nextToken), + updatedAt: Date.now(), + browserSessionKey: session.browserSessionKey || session.fingerprint, + source: "browser", + }; + 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}, 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)}` + ); + return null; + } +} + +/** + * Resolve a durable Firefly session from stored credentials. + * Caches in memory + DATA_DIR; rebuilds ARP from cookies; optionally warms via durable CDP. + */ +export async function ensureAdobeFireflySession( + opts: AdobeFireflySessionResolveOpts +): Promise { + const blobs = collectCredentialBlobs(opts.credentials); + if (blobs.length === 0) { + throw new AdobeFireflyError( + "Adobe Firefly credentials missing. Paste the IMS JWT (Authorization: Bearer on firefly-3p) " + + "and ideally the full firefly.adobe.com Cookie (with sherlockToken / forterToken / arkose) once.", + 401, + "missing_credentials" + ); + } + + const joined = blobs.join("\n"); + // 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); + + // 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 = ""; + let cookie = ""; + let pasteHadUserJwt = false; + + // Prefer JWT from the live paste (authoritative for this request) + for (const b of blobs) { + const tok = extractAdobeCredentialToken(b); + if (looksLikeAdobeJwt(tok) && isAdobeUserAccessToken(tok)) { + accessToken = tok; + pasteHadUserJwt = true; + break; + } + } + // 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 ( + cached?.accessToken && + isAdobeUserAccessToken(cached.accessToken) && + cachedExpiresAt - Date.now() >= JWT_REFRESH_SKEW_MS && + (!accessToken || pastedExpiresAt - Date.now() < JWT_REFRESH_SKEW_MS) + ) { + accessToken = cached.accessToken; + pasteHadUserJwt = false; + } + + // Cookie blob + for (const b of blobs) { + const c = extractAdobeCookieHeader(b); + if (c) { + cookie = c; + break; + } + if (looksLikeAdobeCookieBlob(b)) { + cookie = extractAdobeCookieHeader(b) || b; + break; + } + } + if (!cookie && cached?.cookie) cookie = cached.cookie; + if (cached?.cookie && cookie) cookie = mergeAdobeCookieHeaders(cached.cookie, cookie); + + // Cookie-only or near-expiry JWT → try IMS exchange (needs real IMS cookies on adobelogin.com) + const tokenExpiresAt = accessToken ? estimateAdobeTokenExpiry(accessToken) : 0; + const needJwtRefresh = + !accessToken || + !pasteHadUserJwt || + (tokenExpiresAt > 0 && tokenExpiresAt - Date.now() < JWT_REFRESH_SKEW_MS); + + if (needJwtRefresh && cookie) { + try { + const refreshed = await exchangeAdobeCookieForAccessToken(cookie, fetchImpl); + if (isAdobeUserAccessToken(refreshed)) { + accessToken = refreshed; + opts.log?.info?.("ADOBE-FIREFLY", "IMS cookie exchange produced a user JWT"); + } + } catch { + // Fall through — pure firefly cookies still yield guest-only; keep existing JWT. + } + } + + const cookieBlob = cookie || extractAdobeCookieHeader(joined) || ""; + + if (!accessToken) { + // Try the pure-HTTP resolve (paste JWT / IMS exchange). When the browser engine is on, + // a missing/guest token is NOT fatal here — the off-screen Chrome warm below reads the + // live user JWT from a signed-in profile (the "one-time browser sign-in" path). Only + // surface the guest/missing error when the browser engine is disabled. + try { + accessToken = await resolveAdobeAccessToken(opts.credentials, fetchImpl); + } catch (err) { + if (!adobeFireflyBrowserEnabled()) throw err; + opts.log?.info?.( + "ADOBE-FIREFLY", + "no user JWT from paste/cookie — will read it from the signed-in Chrome profile" + ); + } + } + + const cookieForSession = cookie || cookieBlob; + const forterTs = extractAdobeForterTimestampMs(cookieForSession); + const working = lastWorkingArpByFingerprint.get(fingerprint); + const workingFresh = + working && Date.now() - working.at < WORKING_ARP_STICKY_MS ? working.arp : ""; + + // Prefer last ARP that actually got generate-async 2xx (batch stability). + // Rebuild from cookie pieces / sherlockToken — pure HTTP, no browser. + let arpSessionId = ""; + if (!opts.forceRefresh && !opts.rotateArp && workingFresh) { + arpSessionId = workingFresh; + } else if (!opts.forceRefresh && !opts.rotateArp && cached?.arpSessionId) { + arpSessionId = cached.arpSessionId; + } else { + arpSessionId = resolveAdobeArpSessionIdSmart(cookieForSession || joined, { + rotate: Boolean(opts.rotateArp), + }); + } + + let session: AdobeFireflySession = { + accessToken, + cookie: cookieForSession, + arpSessionId: String(arpSessionId || ""), + 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 + // first submit doesn't eat a colligo 408, and so a signed-in profile can supply the user + // JWT with no JWT/cookie paste ("one-time browser sign-in" model): + // - explicit forceRefresh / rotateArp, or + // - 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 || 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); + if (!inflight) { + inflight = refreshAdobeSessionViaBrowser(session, opts.log, { + force: true, + proveWithPing: Boolean(opts.forceRefresh), + }).finally(() => { + browserRefreshInFlight.delete(key); + }); + browserRefreshInFlight.set(key, inflight); + } + const warmed = await inflight; + if (warmed) { + session = { ...warmed, fingerprint }; + opts.log?.info?.( + "ADOBE-FIREFLY", + `durable CDP session warm applied (reason=${opts.forceRefresh ? "force" : opts.rotateArp ? "rotate" : jwtNeedsBrowserRefresh ? "jwt-expiry" : "stale-forter"})` + ); + } + } + + // Final ARP if still empty + if (!session.arpSessionId) { + session.arpSessionId = resolveAdobeArpSessionIdSmart(session.cookie || joined); + } + // Re-apply sticky working ARP if warm did not produce a newer forter-based ARP + if (workingFresh && !opts.forceRefresh && !opts.rotateArp) { + const warmForterTs = extractAdobeForterTimestampMs(session.cookie); + if (!(warmForterTs > forterTs)) { + session.arpSessionId = workingFresh; + session.source = "cache"; + } + } + + // No usable AdobeID user JWT after the warm → marker-only credentials or cold profile. + 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 ' + + "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" + ); + } + + // 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; +} + +/** + * After a colligo 408: clear sticky ARP, try browser warm for a NEW forter, fall back carefully. + * Rebuilding from the same forter cookie is a no-op and must not burn all retries. + * + * Policy: + * - Fresh forter + attempt 1–2 → quiet reuse (rate-limit masquerading as 408). + * - Stale forter (age > FORTER_STALE_MS) OR attempt ≥ 3 → off-screen Chrome warm immediately. + */ +export async function rotateAdobeFireflySessionOnError( + session: AdobeFireflySession, + opts?: { + tryBrowser?: boolean; + 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); + const forterAgeMs = forterTs > 0 ? Math.max(0, Date.now() - forterTs) : null; + // Only treat as "known stale" when the cookie embeds a forter timestamp we can age. + // Unknown age (synthetic ARP / tests) keeps the quiet 1–2 reuse path. + const forterKnownStale = forterAgeMs != null && forterAgeMs > FORTER_STALE_MS; + + // 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 && !opts?.authFailure) { + const same: AdobeFireflySession = { + ...session, + updatedAt: Date.now(), + source: "cache", + }; + sessionCache.set(session.fingerprint, same); + saveDiskSession(same); + opts?.log?.info?.( + "ADOBE-FIREFLY", + `408 recovery: reusing ARP (quiet period, attempt ${attempt}, forterAgeMs=${forterAgeMs ?? "unknown"})` + ); + return same; + } + + // 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(); + + const tryBrowser = + opts?.tryBrowser !== false && process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "0"; + if (tryBrowser) { + opts?.log?.info?.( + "ADOBE-FIREFLY", + `${opts?.authFailure ? "auth" : "408"} recovery: durable CDP warm (attempt=${attempt}, forterKnownStale=${forterKnownStale}, forterAgeMs=${forterAgeMs ?? "unknown"})` + ); + const warmed = await refreshAdobeSessionViaBrowser(session, opts?.log, { + force: true, + proveWithPing: true, + }); + if (warmed?.arpSessionId) { + const next = { ...warmed, fingerprint: session.fingerprint }; + sessionCache.set(session.fingerprint, next); + saveDiskSession(next); + opts?.log?.info?.( + "ADOBE-FIREFLY", + `${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 next: AdobeFireflySession = { + ...session, + arpSessionId: rebuilt && rebuilt !== prevArp ? rebuilt : session.arpSessionId, + updatedAt: Date.now(), + source: "rebuild", + }; + sessionCache.set(session.fingerprint, next); + saveDiskSession(next); + return next; +} + +/** Test helper — clear in-memory session cache. */ +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 349bf3b6ab..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, @@ -35,69 +170,35 @@ export async function POST( return NextResponse.json({ success: false, error: "Provider not found" }, { status: 404 }); } - const body = await req.json().catch(() => ({})); - const timeout = typeof body.timeout === "number" ? body.timeout : undefined; + const body = (await req.json().catch(() => ({}))) as { + timeout?: unknown; + freshSession?: unknown; + }; const providerSlug = resolveProviderSlug(provider as Record); - try { - // Adobe Firefly is special: the IMS JWT is only ever in the Authorization - // header of firefly-3p.ff.adobe.io XHRs (never cookies/localStorage), so - // the generic cookie-extraction service cannot capture it. Use a dedicated - // Playwright service that intercepts that request instead. - if (ADOBE_FIREFLY_SLUGS.has(providerSlug)) { - const { startAdobeFireflyBrowserLogin } = - await import("@omniroute/open-sse/services/adobeFireflyBrowserLogin.ts"); - const fireflyResult = await startAdobeFireflyBrowserLogin(timeout); - - if (fireflyResult.success && fireflyResult.credentials) { - const credentials = fireflyResult.credentials; - try { - // Store the JWT in apiKey (where resolveAdobeAccessToken looks first) - // and the cookie + access_token in providerSpecificData (camelCase — - // updateProviderConnection ignores snake_case keys). - const providerSpecificData: Record = {}; - if (credentials.accessToken) { - providerSpecificData.access_token = credentials.accessToken; - } - if (credentials.cookie) { - providerSpecificData.cookie = credentials.cookie; - } - - await updateProviderConnection(id, { - apiKey: credentials.accessToken || "", - providerSpecificData, - }); - - return NextResponse.json({ - success: true, - accessToken: credentials.accessToken || "", - cookie: credentials.cookie || "", - account: fireflyResult.account || "", - credentials: providerSpecificData, - persisted: true, - }); - } catch (err) { - const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err); - return NextResponse.json( - { success: false, error: `Extracted but failed to persist: ${msg}` }, - { status: 500 } - ); - } - } - + // Adobe Firefly: dedicated JWT capture (never cookies/localStorage alone). + if (isAdobeFireflyProvider(provider as { provider?: unknown }, providerSlug)) { + try { + return await loginAdobeFirefly(id, body); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err); return NextResponse.json( - { success: false, error: fireflyResult.error || "Adobe Firefly sign-in failed" }, - { status: 400 } + { success: false, error: `Adobe Firefly sign-in error: ${msg}` }, + { status: 500 } ); } + } + try { // Generic web-cookie path: pass the provider SLUG (not the DB id) so // TOKEN_EXTRACTION_CONFIGS can find the extraction config. // Bug: the previous code passed `id` (connection UUID), so the lookup always // 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 775ed6bf21..d22cfb4e89 100644 --- a/tests/unit/adobe-firefly-browser-login.test.ts +++ b/tests/unit/adobe-firefly-browser-login.test.ts @@ -1,14 +1,23 @@ /** * Pure-function tests for Adobe Firefly browser login helpers. - * (No Playwright launch тАФ that path is integration-only.) + * (No Playwright launch — that path is integration-only.) */ 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"; @@ -35,10 +44,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([]), ""); }); @@ -51,9 +66,144 @@ test("accountLabelFromAdobeJwt prefers email", () => { assert.equal(accountLabelFromAdobeJwt("not-a-jwt"), ""); }); +test("accountLabelFromAdobeJwt never exposes opaque Adobe IDs", () => { + const payload = Buffer.from( + JSON.stringify({ user_id: "0123456789ABCDEF@AdobeID", sub: "opaque-subject" }) + ).toString("base64url"); + assert.equal(accountLabelFromAdobeJwt(`eyJhbGciOiJIUzI1NiJ9.${payload}.sig`), ""); +}); + +test("resolveAdobeAccountLabel uses IMS display name and generic fallback", async () => { + const payload = Buffer.from( + JSON.stringify({ client_id: "clio-playground-web", user_id: "opaque@AdobeID" }) + ).toString("base64url"); + const jwt = `eyJhbGciOiJIUzI1NiJ9.${payload}.sig`; + const displayName = await resolveAdobeAccountLabel( + jwt, + (async () => + new Response(JSON.stringify({ name: "Friendly Name", sub: "opaque@AdobeID" }), { + status: 200, + })) as typeof fetch + ); + assert.equal(displayName, "Friendly Name"); + + const fallback = await resolveAdobeAccountLabel( + jwt, + (async () => new Response("unavailable", { status: 503 })) as typeof fetch + ); + assert.equal(fallback, "Adobe account"); +}); + +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")); + assert.notEqual(firstKey, secondKey); + + const interactive = buildAdobeFireflyBrowserArgs({ + port: 9222, + userDataDir: `C:\\profiles\\${firstKey}`, + interactive: true, + freshSession: true, + }); + // 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 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", () => { + assert.deepEqual( + filterAdobeBrowserCookies([ + { name: "ims", value: "one", domain: ".adobelogin.com", secure: true }, + { name: "firefly", value: "two", domain: "firefly.adobe.com" }, + { name: "service", value: "three", domain: "firefly-3p.ff.adobe.io" }, + { name: "unrelated", value: "secret", domain: ".example.com" }, + { name: "bad", value: "line\nbreak", domain: ".adobe.com" }, + ]).map((cookie) => cookie.name), + ["ims", "firefly", "service"] + ); +}); + test("resolveSystemBrowserExecutable finds Chrome or Edge on this host (or honors env)", () => { const path = resolveSystemBrowserExecutable(); - // CI images may lack a browser тАФ only assert type / env override behavior. + // CI images may lack a browser — only assert type / env override behavior. if (path) { assert.equal(typeof path, "string"); assert.ok(path.length > 0); @@ -71,9 +221,9 @@ test("error path does not mention Playwright (packaged backend has no Playwright const { startAdobeFireflyBrowserLogin } = await import("../../open-sse/services/adobeFireflyBrowserLogin.ts"); // resolveSystemBrowserExecutable still finds real Chrome before env if env - // path does not exist тАФ force by temporarily only using missing env: + // path does not exist — force by temporarily only using missing env: // when path is missing, existsSync fails and falls through to candidates. - // If Chrome exists on the machine this will open a browser тАФ skip live launch. + // If Chrome exists on the machine this will open a browser — skip live launch. // Instead assert the static error string for the no-browser branch: const msg = "No Chrome or Edge browser found for Adobe Firefly sign-in. " + diff --git a/tests/unit/adobe-firefly.test.ts b/tests/unit/adobe-firefly.test.ts index 258bea7036..d4deaa70a7 100644 --- a/tests/unit/adobe-firefly.test.ts +++ b/tests/unit/adobe-firefly.test.ts @@ -4,13 +4,8 @@ import { resolvePublicCred } from "../../open-sse/utils/publicCreds.ts"; import { ADOBE_FIREFLY_IMAGE_MODELS, ADOBE_FIREFLY_VIDEO_MODELS, - ADOBE_FIREFLY_IMAGE_TIMEOUT_MAX_MS, - ADOBE_FIREFLY_IMAGE_TIMEOUT_PER_REF_MS, - DEFAULT_IMAGE_TIMEOUT_MS, adobeFireflyApiKey, adobeFireflyBalanceApiKey, - adobeFireflyImageTimeoutMs, - adobeFireflyMaxImageRefs, buildAdobeImagePayload, buildAdobePollHeaders, buildAdobeSubmitHeaders, @@ -78,11 +73,6 @@ test("adobe-firefly is registered in IMAGE_PROVIDERS with adobe-firefly-image fo assert.equal(entry.format, "adobe-firefly-image"); assert.match(entry.baseUrl, /firefly-3p\.ff\.adobe\.io/); assert.ok(Array.isArray(entry.models) && entry.models.length >= 4); - assert.equal( - entry.models.some((model: { id: string }) => model.id === "nano-banana-pro"), - false, - "routing-only compatibility aliases must not be advertised as discovered models" - ); }); test("adobe-firefly is registered in VIDEO_PROVIDERS with adobe-firefly-video format", () => { @@ -98,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" }, }); @@ -159,25 +152,20 @@ test("normalizeAdobeOutputResolution maps quality tiers", () => { assert.equal(normalizeAdobeOutputResolution(undefined, undefined), "2K"); }); -test("resolveAdobeImageModel maps valid aliases to exact discovery ids", () => { - assert.equal(resolveAdobeImageModel("nano-banana-pro").id, "gemini-flash-nano-banana-2"); - assert.equal( - resolveAdobeImageModel("adobe-firefly/nano-banana-2").id, - "gemini-flash-nano-banana-3" - ); - assert.equal(resolveAdobeImageModel("gpt-image").id, "gpt-image-2"); - assert.throws( - () => resolveAdobeImageModel("invented-image-model"), - /Unknown Adobe Firefly image model/ - ); +test("resolveAdobeImageModel maps catalog and long model ids", () => { + assert.equal(resolveAdobeImageModel("nano-banana-pro").id, "nano-banana-pro"); + assert.equal(resolveAdobeImageModel("adobe-firefly/nano-banana-2").id, "nano-banana-2"); + assert.equal(resolveAdobeImageModel("firefly-nano-banana-pro-2k-16x9").id, "nano-banana-pro"); + assert.equal(resolveAdobeImageModel("gpt-image").id, "gpt-image"); assert.ok(ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"].upstreamModelVersion); }); -test("resolveAdobeVideoModel maps only discovered video models", () => { - assert.equal(resolveAdobeVideoModel("veo-3.1-fast").id, "veo-3.1-fast-generate"); - assert.equal(resolveAdobeVideoModel("kling-3").id, "kling-kling-v3-standard-i2v"); - assert.throws(() => resolveAdobeVideoModel("sora-2"), /Unknown Adobe Firefly video model/); - assert.ok(ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1"].defaultDuration > 0); +test("resolveAdobeVideoModel maps sora/veo/kling families", () => { + assert.equal(resolveAdobeVideoModel("sora-2").id, "sora-2"); + assert.equal(resolveAdobeVideoModel("firefly-sora2-pro-8s-16x9").id, "sora-2-pro"); + assert.equal(resolveAdobeVideoModel("veo-3.1-fast").id, "veo-3.1-fast"); + assert.equal(resolveAdobeVideoModel("kling-3").id, "kling-3"); + assert.ok(ADOBE_FIREFLY_VIDEO_MODELS["sora-2"].defaultDuration > 0); }); test("buildAdobeImagePayload produces nano and gpt-image shapes", () => { @@ -205,48 +193,6 @@ test("buildAdobeImagePayload produces nano and gpt-image shapes", () => { assert.equal((gpt.modelSpecificPayload as Record).size, "auto"); assert.equal(gpt.size, undefined); assert.equal(gpt.outputResolution, undefined); - - // Missing / auto quality → maximal detail (5). Explicit low/medium still honored. - const gptDefault = buildAdobeImagePayload({ - prompt: "a dog", - aspectRatio: "1:1", - outputResolution: "1K", - modelSpec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image-2"], - }); - assert.equal((gptDefault.generationSettings as Record).detailLevel, 5); - const gptAuto = buildAdobeImagePayload({ - prompt: "a dog", - aspectRatio: "1:1", - outputResolution: "1K", - modelSpec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image"], - quality: "auto", - }); - assert.equal((gptAuto.generationSettings as Record).detailLevel, 5); - const gptLow = buildAdobeImagePayload({ - prompt: "a dog", - aspectRatio: "1:1", - outputResolution: "1K", - modelSpec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image"], - quality: "low", - }); - assert.equal((gptLow.generationSettings as Record).detailLevel, 1); - const gptMedium = buildAdobeImagePayload({ - prompt: "a dog", - aspectRatio: "1:1", - outputResolution: "1K", - modelSpec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image"], - quality: "medium", - }); - assert.equal((gptMedium.generationSettings as Record).detailLevel, 3); - // Firefly UI resolution tiers map onto the same detailLevel scale. - const gpt4k = buildAdobeImagePayload({ - prompt: "a dog", - aspectRatio: "1:1", - outputResolution: "1K", - modelSpec: ADOBE_FIREFLY_IMAGE_MODELS["gpt-image"], - quality: "4k", - }); - assert.equal((gpt4k.generationSettings as Record).detailLevel, 5); }); test("buildAdobeImagePayload attaches referenceBlobs like live adobe_atach_images capture", () => { @@ -275,19 +221,12 @@ test("buildAdobeImagePayload attaches referenceBlobs like live adobe_atach_image sourceImageIds: ["aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"], }); assert.deepEqual(gpt.referenceBlobs, [ - { id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", usage: "source" }, + { id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", usage: "subject" }, ]); - assert.equal((gpt.generationMetadata as Record).module, "image2image"); -}); - -test("adobeFireflyImageTimeoutMs scales boundedly with reference count", () => { - assert.equal(adobeFireflyImageTimeoutMs({ refCount: 0 }), DEFAULT_IMAGE_TIMEOUT_MS); assert.equal( - adobeFireflyImageTimeoutMs({ refCount: 2 }), - DEFAULT_IMAGE_TIMEOUT_MS + 2 * ADOBE_FIREFLY_IMAGE_TIMEOUT_PER_REF_MS + (gpt.generationMetadata as Record).module, + "image2image" ); - assert.equal(adobeFireflyImageTimeoutMs({ timeoutMs: 120_000, refCount: 5 }), 120_000); - assert.equal(adobeFireflyImageTimeoutMs({ refCount: 99 }), ADOBE_FIREFLY_IMAGE_TIMEOUT_MAX_MS); }); test("extractAdobeSourceImageSources reads Media page image fields", () => { @@ -321,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"); @@ -362,7 +303,16 @@ test("resolveAdobeSourceImageIds uploads data URLs then returns blob ids", async assert.equal(ADOBE_FIREFLY_IMAGE_UPLOAD_URL.includes("storage/image"), true); }); -test("buildAdobeVideoPayload follows discovered fields and reference roles", () => { +test("buildAdobeVideoPayload produces sora and veo shapes", () => { + const sora = buildAdobeVideoPayload({ + prompt: "ocean waves", + aspectRatio: "16:9", + duration: 8, + modelSpec: ADOBE_FIREFLY_VIDEO_MODELS["sora-2"], + }); + assert.equal(sora.modelId, "sora"); + assert.equal(sora.duration, 8); + const veo = buildAdobeVideoPayload({ prompt: "city flyover", aspectRatio: "9:16", @@ -371,39 +321,25 @@ test("buildAdobeVideoPayload follows discovered fields and reference roles", () }); assert.equal(veo.modelId, "veo"); assert.equal(veo.modelVersion, "3.1-generate"); - assert.equal(veo.duration, 6); - assert.equal(veo.generateAudio, true); - - const kling = buildAdobeVideoPayload({ - prompt: "ocean waves", - aspectRatio: "16:9", - duration: 5, - modelSpec: ADOBE_FIREFLY_VIDEO_MODELS["kling-3"], - sourceImageIds: ["aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"], - }); - assert.equal(kling.modelVersion, "kling_v3_standard_i2v"); - assert.deepEqual(kling.referenceBlobs, [ - { id: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", usage: "frame", order: 1 }, - ]); - assert.throws( - () => - buildAdobeVideoPayload({ - prompt: "bad duration", - aspectRatio: "16:9", - duration: 5, - modelSpec: ADOBE_FIREFLY_VIDEO_MODELS["veo-3.1"], - }), - /supports duration/ + assert.equal( + (veo.modelSpecificPayload as Record>).parameters + .durationSeconds, + 6 ); + assert.equal(veo.generateAudio, true); }); 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" ); }); @@ -475,17 +411,46 @@ test("buildAdobeSubmitNonce is sha256(user_id + prompt[:256])", async () => { assert.notEqual(buildAdobeSubmitNonce(token, prompt + "!"), nonce); assert.equal(extractAdobeAccountIdFromToken(token), "0EB681AF6A5FF6C10A495FF2@AdobeID"); + const { + isValidAdobeArpSessionId, + resolveAdobeArpSessionId, + extractAdobeArpSessionId, + ADOBE_FIREFLY_FTR_MAGIC, + } = await import("../../open-sse/services/adobeFireflyClient.ts"); const arp = buildAdobeArpSessionId(); assert.ok(arp.length > 20); + assert.equal(isValidAdobeArpSessionId(arp), true); const decoded = JSON.parse(Buffer.from(arp, "base64").toString("utf8")); assert.ok(decoded.sid); - assert.match(String(decoded.ftr), /dUAL43-mnts-ants-d4_31ck__tt$/); + // Live SPA shape (2026-07): sid + ark (Arkose) + ftr + bfp + fpjs + assert.ok(decoded.ark, "synthetic ARP must include ark field"); + assert.match(String(decoded.ark), /pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C/); + assert.match(String(decoded.ftr), new RegExp(ADOBE_FIREFLY_FTR_MAGIC)); + assert.match(String(decoded.ftr), /-v2_tt$/); + assert.ok(decoded.bfp, "synthetic ARP must include bfp (browser fingerprint)"); + assert.ok(decoded.fpjs, "synthetic ARP must include fpjs"); // Headers: deterministic nonce + always ARP (synthetic when none provided) const h = buildAdobeSubmitHeaders(token, { prompt }); assert.equal(h["x-nonce"], nonce); assert.ok(h["x-arp-session-id"]); assert.equal(h.cookie, undefined); + + // Prefer real sherlockToken / x-arp-session-id from paste over synthetic + const realArp = Buffer.from( + JSON.stringify({ + sid: "11111111-2222-3333-4444-555555555555", + ark: "sess.123|r=eu-west-1|pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C", + ftr: "aa_1" + ADOBE_FIREFLY_FTR_MAGIC + "_x=-1-v2_tt", + }), + "utf8" + ).toString("base64"); + assert.equal(extractAdobeArpSessionId(`a=1; sherlockToken=${realArp}; b=2`), realArp); + assert.equal( + extractAdobeArpSessionId(`x-arp-session-id: ${realArp}\nAuthorization: Bearer x`), + realArp + ); + assert.equal(resolveAdobeArpSessionId(`sherlockToken=${realArp}`), realArp); }); test("normalizeAdobePollUrl rewrites firefly-epo jobs/result to BKS", () => { @@ -503,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); @@ -529,7 +496,7 @@ test("adobe-firefly is in USAGE_SUPPORTED_PROVIDERS for Limits", () => { assert.ok(USAGE_SUPPORTED_PROVIDERS.includes("firefly")); }); -test("parseAdobeModelsDiscovery preserves schemas and maps exact ids", () => { +test("parseAdobeModelsDiscovery extracts image/video versions", () => { const rows = parseAdobeModelsDiscovery({ models: [ { @@ -540,44 +507,16 @@ test("parseAdobeModelsDiscovery preserves schemas and maps exact ids", () => { outputModality: ["image"], modelDisplayName: "Gemini 3.0 (Nano Banana Pro)", healthStatus: "HEALTHY", - inputMediaUseCase: ["editing"], - bksGenerationModel: "firefly_3p:external:gemini_flash_2", - requestSchema: { - type: "object", - properties: { - prompt: { type: "string" }, - referenceBlobs: { - maxItems: 14, - "x-capabilities": [ - { - mediaType: "image", - usageConstraints: [{ usageType: "general", minItems: 0, maxItems: 14 }], - maxFileSizeBytes: 104857600, - }, - ], - }, - }, - }, }, }, }, { - modelId: "veo", + modelId: "sora", modelVersions: { - "3.1-generate": { + "sora-2": { enabled: true, outputModality: ["video"], - modelDisplayName: "Veo 3.1", - requestSchema: { - allOf: [ - { - properties: { - prompt: { type: "string" }, - duration: { anyOf: [{ type: "integer", enum: [4, 6, 8] }] }, - }, - }, - ], - }, + modelDisplayName: "Sora 2", }, }, }, @@ -587,35 +526,14 @@ test("parseAdobeModelsDiscovery preserves schemas and maps exact ids", () => { assert.equal(rows[0].modality, "image"); assert.equal(rows[1].modality, "video"); const catalog = mapDiscoveredToCatalog(rows); - assert.ok(catalog.some((m) => m.id === "gemini-flash-nano-banana-2")); - assert.ok(catalog.some((m) => m.id === "veo-3.1-generate")); - assert.equal(catalog[0].capabilities.referenceInputs[0].maxItems, 14); - assert.deepEqual(catalog[1].capabilities.supportedDurations, [4, 6, 8]); + assert.ok(catalog.some((m) => m.id === "nano-banana-pro")); + assert.ok(catalog.some((m) => m.id === "sora-2")); }); -test("fallback catalog is the verified discovery snapshot without invented Sora", () => { - assert.equal(ADOBE_FIREFLY_FALLBACK_MODELS.length, 52); - assert.equal(getAdobeFireflyFallbackCatalog("image").length, 17); - assert.equal(getAdobeFireflyFallbackCatalog("video").length, 35); - assert.equal( - ADOBE_FIREFLY_FALLBACK_MODELS.some((model) => model.id.includes("sora")), - false - ); - assert.equal( - ADOBE_FIREFLY_FALLBACK_MODELS.some( - (model) => model.id.includes("kling") && model.id.includes("omni") - ), - false - ); - assert.ok(ADOBE_FIREFLY_FALLBACK_MODELS.some((model) => model.id === "kling-kling-o3")); - assert.equal( - ADOBE_FIREFLY_IMAGE_MODELS["nano-banana-pro"].capabilities.referenceInputs[0].maxItems, - 14 - ); - assert.equal( - ADOBE_FIREFLY_IMAGE_MODELS["gpt-image"].capabilities.referenceInputs[0].maxItems, - 16 - ); +test("fallback catalog has image and video entries from get_models capture", () => { + assert.ok(ADOBE_FIREFLY_FALLBACK_MODELS.length >= 10); + assert.ok(getAdobeFireflyFallbackCatalog("image").length >= 4); + assert.ok(getAdobeFireflyFallbackCatalog("video").length >= 4); }); test("extractAdobeAccountIdFromToken reads user_id claim", () => { @@ -630,7 +548,18 @@ test("extractAdobeAccountIdFromToken reads user_id claim", () => { // --- Handlers (mocked fetch) ---------------------------------------------- function jsonResponse(status: number, body: unknown, headerMap: Record = {}) { - return new Response(JSON.stringify(body) ?? null, { status, headers: headerMap }); + return { + ok: status >= 200 && status < 300, + status, + headers: { + get: (name: string) => { + const key = Object.keys(headerMap).find((k) => k.toLowerCase() === name.toLowerCase()); + return key ? headerMap[key] : null; + }, + }, + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response; } test("handleAdobeFireflyImageGeneration returns 400 when prompt is missing", async () => { @@ -648,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") @@ -729,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" }]); }); @@ -755,7 +691,7 @@ test("adobeFireflyGenerateVideo submit+poll happy path (mocked)", async () => { const result = await adobeFireflyGenerateVideo({ accessToken: "tok", prompt: "drone over forest", - model: "veo-3.1", + model: "sora-2", duration: 4, aspectRatio: "16:9", fetchImpl: fetchImpl as typeof fetch, @@ -766,7 +702,7 @@ test("adobeFireflyGenerateVideo submit+poll happy path (mocked)", async () => { test("handleAdobeFireflyVideoGeneration returns 400 without prompt", async () => { const result = await handleAdobeFireflyVideoGeneration({ - model: "veo-3.1", + model: "sora-2", provider: "adobe-firefly", body: {}, credentials: { apiKey: "aaa.bbb.ccc" }, @@ -854,6 +790,112 @@ test("cookie exchange rejects guest IMS tokens", async () => { ); }); +test("extractAdobeArpSessionId recovers JWT+ARP joined by space (PasswordBox mangling)", async () => { + const { extractAdobeArpSessionId, hasBrowserAdobeArpSession, formatAdobeSystemUnderLoadError } = + await import("../../open-sse/services/adobeFireflyClient.ts"); + const realArp = Buffer.from( + JSON.stringify({ + sid: "bdf37b8a-117f-467d-a737-7792932d98b4", + ark: "60818c561473ddb23.0684402805|r=eu-west-1|pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C", + ftr: "aab9dc9eb48f4ee1916428649f908f7d_1__UDF43-m4_31ck_x=-1-v2_tt", + }), + "utf8" + ).toString("base64"); + // Fake 3-segment JWT shape long enough for looksLikeAdobeJwt + const fakeJwt = + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." + + "eyJ1c2VyX2lkIjoiMEVCNjgxQUY2QTVGRjZDMTBBNDk1RkYyQEFkb2JlSUQifQ." + + "sigABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop"; + const joined = `${fakeJwt} ${realArp}`; + assert.equal(extractAdobeArpSessionId(joined), realArp); + assert.equal(hasBrowserAdobeArpSession(joined), true); + assert.equal(hasBrowserAdobeArpSession(fakeJwt), false); + assert.match( + formatAdobeSystemUnderLoadError("image", 2, { hadBrowserArp: false }), + /missing a browser x-arp-session-id/ + ); + assert.match( + formatAdobeSystemUnderLoadError("image", 2, { hadBrowserArp: true }), + /auto-rebuilds x-arp-session-id|Cookie once|Forter\/Arkose/i + ); +}); + +test("extractAdobeArpSessionId does not pick aux_sid over sherlockToken", async () => { + 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({ + sid: "bdf37b8a-117f-467d-a737-7792932d98b4", + ark: "10618c58b8d3cd588.3119555905|r=eu-west-1|pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C", + ftr: `aa_${Date.now()}${ADOBE_FIREFLY_FTR_MAGIC}_x=-1-v2_tt`, + }), + "utf8" + ).toString("base64"); + // Long aux_sid must NOT win ranking (this was the live 408 root cause) + const aux = "A" + "x".repeat(780); + const cookie = `ff_session_guid=bdf37b8a-117f-467d-a737-7792932d98b4; sherlockToken=${realArp}; aux_sid=${aux}; forterToken=x`; + const got = extractAdobeArpSessionId(cookie); + assert.equal(got, realArp); + assert.equal(isValidAdobeArpSessionId(`aux_sid=${aux}`), false); + assert.equal(isValidAdobeArpSessionId(realArp), true); +}); + +test("rebuild ARP from cookie components (forter+arkose+sid)", async () => { + const { + buildAdobeArpSessionIdFromCookies, + canRebuildAdobeArpFromCookies, + mergeAdobeCookieHeaders, + resolveAdobeArpSessionIdSmart, + serializeAdobeFireflyCredential, + normalizeAdobeForterToken, + } = await import("../../open-sse/services/adobeFireflySession.ts"); + const { ADOBE_FIREFLY_FTR_MAGIC } = await import("../../open-sse/services/adobeFireflyClient.ts"); + + const ftr = `aab9dc9eb48f4ee1916428649f908f7d_${Date.now()}${ADOBE_FIREFLY_FTR_MAGIC}_x=-1092-v2_tt`; + const ark = + "87818c58b11662a57.5347274705|r=eu-west-1|meta=3|pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C|at=40"; + const cookie = + `ff_session_guid=bdf37b8a-117f-467d-a737-7792932d98b4; arkose=${ark}; ` + + `forterToken=${encodeURIComponent(ftr)}; bfp=58ef2899-b1c4-42e4-9625-ae265e1b4994; ` + + `fpjs=${encodeURIComponent(JSON.stringify({ requestId: "1.x", visitorId: "v" }))}`; + + assert.equal(canRebuildAdobeArpFromCookies(cookie), true); + const arp = buildAdobeArpSessionIdFromCookies(cookie); + assert.ok(arp.length > 40); + const decoded = JSON.parse(Buffer.from(arp, "base64").toString("utf8")); + assert.equal(decoded.sid, "bdf37b8a-117f-467d-a737-7792932d98b4"); + assert.equal(decoded.ark, ark); + assert.ok(String(decoded.ftr).includes(ADOBE_FIREFLY_FTR_MAGIC)); + assert.equal(decoded.bfp, "58ef2899-b1c4-42e4-9625-ae265e1b4994"); + assert.ok(decoded.fpjs); + + // forter without _tt suffix gets normalized + assert.match(normalizeAdobeForterToken("abc_1__UDF43-m4_31ck_x=-1-v2"), /-v2_tt$/); + // localStorage comma form is rejected + assert.equal(normalizeAdobeForterToken("aab9dc9eb48f4ee1916428649f908f7d,1784986682306"), ""); + + const merged = mergeAdobeCookieHeaders( + "ff_session_guid=old; arkose=a1", + "arkose=a2; forterToken=newftr" + ); + assert.match(merged, /arkose=a2/); + assert.match(merged, /forterToken=newftr/); + assert.match(merged, /ff_session_guid=old/); + + // Smart resolve prefers rebuild when cookie pieces present + const smart = resolveAdobeArpSessionIdSmart(cookie); + assert.equal(smart, arp); + + const ser = serializeAdobeFireflyCredential({ + accessToken: "eyJ.token.sig", + cookie, + arpSessionId: arp, + }); + assert.match(ser, /eyJ\.token\.sig/); + assert.match(ser, /ff_session_guid=/); +}); + test("isAdobeTransientSubmitError detects 408 system under load", () => { assert.equal( isAdobeTransientSubmitError( @@ -900,6 +942,10 @@ 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"); + __resetAdobeFireflySessionCacheForTests(); + let submits = 0; const userTok = userImsJwt(); const fetchImpl = async (url: string) => { @@ -907,7 +953,10 @@ 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" } } }, {}); } @@ -930,7 +979,106 @@ test("image submit retries on 408 then succeeds", async () => { assert.match(result.url, /retry\.png/); }); +test("sticky ARP: successful submit is reused by ensure on next call", async () => { + const { + __resetAdobeFireflySessionCacheForTests, + markAdobeFireflyArpSuccess, + ensureAdobeFireflySession, + fingerprintAdobeCredential, + } = await import("../../open-sse/services/adobeFireflySession.ts"); + const { ADOBE_FIREFLY_FTR_MAGIC } = await import("../../open-sse/services/adobeFireflyClient.ts"); + __resetAdobeFireflySessionCacheForTests(); + + const userTok = userImsJwt(); + const ftr = `aab9dc9eb48f4ee1916428649f908f7d_${Date.now()}${ADOBE_FIREFLY_FTR_MAGIC}_x=-1-v2_tt`; + const ark = + "87818c58b11662a57.5347274705|r=eu-west-1|meta=3|pk=BBCC314C-4937-4CCD-B0A3-FDF0F0F7603C|at=40"; + const cookie = + `ff_session_guid=bdf37b8a-117f-467d-a737-7792932d98b4; arkose=${ark}; ` + + `forterToken=${encodeURIComponent(ftr)}`; + const cred = `${userTok}\n${cookie}`; + const fp = fingerprintAdobeCredential(cred); + const stickyArp = Buffer.from( + JSON.stringify({ sid: "sticky-sid", ark: "sticky-ark", ftr: "sticky-ftr" }), + "utf8" + ).toString("base64"); + + markAdobeFireflyArpSuccess(fp, stickyArp); + + const session = await ensureAdobeFireflySession({ + credentials: { apiKey: cred }, + allowBrowserRefresh: false, + fetchImpl: (async () => { + throw new Error("no network expected"); + }) as typeof fetch, + }); + assert.equal(session.arpSessionId, stickyArp, "ensure must stick to last successful ARP"); + assert.equal(session.fingerprint, fp); +}); + +test("rotateAdobeFireflySessionOnError: attempt1-2 reuse sticky; attempt3 keeps ARP without browser", async () => { + const { + __resetAdobeFireflySessionCacheForTests, + rotateAdobeFireflySessionOnError, + markAdobeFireflyArpSuccess, + buildAdobeArpSessionIdFromCookies, + } = await import("../../open-sse/services/adobeFireflySession.ts"); + const { ADOBE_FIREFLY_FTR_MAGIC } = await import("../../open-sse/services/adobeFireflyClient.ts"); + __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 rebuilt = buildAdobeArpSessionIdFromCookies(cookie); + assert.ok(rebuilt); + + const base = { + accessToken: userImsJwt(), + cookie, + arpSessionId: rebuilt!, + tokenExpiresAt: Date.now() + 3600_000, + updatedAt: Date.now(), + fingerprint: "fp-rotate-test", + source: "paste" as const, + }; + markAdobeFireflyArpSuccess(base.fingerprint, rebuilt!); + + const a1 = await rotateAdobeFireflySessionOnError(base, { + attempt: 1, + tryBrowser: false, + }); + assert.equal(a1.arpSessionId, rebuilt, "attempt 1 reuses ARP (rate-limit quiet)"); + + const a2 = await rotateAdobeFireflySessionOnError( + { ...base, arpSessionId: rebuilt! }, + { attempt: 2, tryBrowser: false } + ); + assert.equal(a2.arpSessionId, rebuilt, "attempt 2 still reuses ARP (mid-batch quiet)"); + + const a3 = await rotateAdobeFireflySessionOnError( + { ...base, arpSessionId: rebuilt! }, + { attempt: 3, tryBrowser: false } + ); + // 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)" + ); + + 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"); + __resetAdobeFireflySessionCacheForTests(); + const userTok = `eyJhbGciOiJSUzI1NiJ9.` + Buffer.from( @@ -948,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 =