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 + conditional fallback-backend 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 =