fix(adobe-firefly): renew sessions through durable CDP

This commit is contained in:
artickc
2026-08-03 01:23:33 +03:00
committed by diegosouzapw
parent faddd4ce18
commit f475971631
5 changed files with 857 additions and 380 deletions

View File

@@ -6,16 +6,15 @@
* cookies/localStorage — the SPA only holds it in memory and attaches it
* as `Authorization: Bearer <jwt>` on XHRs to firefly-3p.ff.adobe.io.
*
* IMPORTANT: The VibeProxyServices.exe is a pkg-packaged Node binary.
* IMPORTANT: The standalone executable is a pkg-packaged Node binary.
* Dynamic `import("playwright")` fails there (native bindings / browsers
* are not in the package). This module launches the **system** Chrome or
* Edge with `--remote-debugging-port` and talks pure Chrome DevTools
* Protocol over WebSocket — zero Playwright dependency.
*/
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { existsSync, mkdirSync } from "node:fs";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { sanitizeErrorMessage } from "../utils/error.ts";
@@ -34,11 +33,40 @@ const CDP_READY_TIMEOUT_MS = 30_000;
export interface AdobeFireflyBrowserLoginResult {
success: boolean;
credentials?: { accessToken?: string; cookie?: string };
arpSessionId?: string;
/** Best-effort Adobe account label (email or user id) decoded from the JWT. */
account?: string;
error?: string;
}
export interface AdobeFireflyCdpRefreshResult {
accessToken: string;
cookie: string;
arpSessionId: string;
}
type AdobeFireflyBrowserLog = {
info?: (...args: unknown[]) => void;
warn?: (...args: unknown[]) => void;
};
let cdpBrowserChain: Promise<void> = Promise.resolve();
/**
* A non-default profile is required by Chrome 136+ for remote debugging. Keep this profile
* durable: Adobe IMS SSO lives here and is what lets a background warm mint the next user JWT.
*/
export function resolveAdobeFireflyBrowserProfileDir(): string {
const dataRoot =
String(process.env.DATA_DIR || process.env.OMNIROUTE_DATA_DIR || "").trim() ||
(process.env.LOCALAPPDATA
? join(process.env.LOCALAPPDATA, "OmniRoute")
: join(process.cwd(), ".data"));
const profile = join(dataRoot, "adobe-chrome-profile");
mkdirSync(profile, { recursive: true });
return profile;
}
export function clampAdobeFireflyLoginTimeout(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_LOGIN_TIMEOUT_MS;
return Math.max(MIN_LOGIN_TIMEOUT_MS, Math.min(MAX_LOGIN_TIMEOUT_MS, Math.trunc(value)));
@@ -54,7 +82,15 @@ export function extractAdobeBearerTokenFromAuthorization(authHeader: string): st
export function buildAdobeFireflyCookieHeader(
cookies: Array<{ name: string; value: string; domain?: string }>
): string {
const wanted = ["sherlockToken", "forterToken", "aux_sid", "ff_session_guid"];
const wanted = [
"sherlockToken",
"forterToken",
"arkose",
"ff_session_guid",
"aux_sid",
"bfp",
"fpjs",
];
const parts: string[] = [];
for (const wantedName of wanted) {
const c = cookies.find(
@@ -161,6 +197,23 @@ async function waitForCdpReady(
type CdpCookie = { name: string; value: string; domain?: string };
function parseCookieHeader(cookieHeader: string): Array<{ name: string; value: string }> {
const cookies: Array<{ name: string; value: string }> = [];
for (const part of String(cookieHeader || "").split(";")) {
const idx = part.indexOf("=");
if (idx <= 0) continue;
const name = part.slice(0, idx).trim();
const value = part.slice(idx + 1).trim();
if (!name || !value || /[\r\n\0]/.test(name + value)) continue;
cookies.push({ name, value });
}
return cookies;
}
function cookieValue(cookies: CdpCookie[], name: string): string {
return cookies.find((cookie) => cookie.name.toLowerCase() === name.toLowerCase())?.value || "";
}
class CdpSocket {
private ws: WebSocket;
private nextId = 1;
@@ -249,14 +302,27 @@ async function captureViaCdp(opts: {
port: number;
browserWsUrl: string;
timeoutMs: number;
}): Promise<{ accessToken: string; cookies: CdpCookie[] }> {
fallbackAccessToken?: string;
seedCookie?: string;
waitForRiskRefresh?: boolean;
}): Promise<{
accessToken: string;
cookies: CdpCookie[];
arpSessionId: string;
}> {
let capturedAccessToken = "";
let capturedArpSessionId = "";
let latestCookies: CdpCookie[] = [];
const pageSockets = new Map<string, CdpSocket>();
let browserCdp: CdpSocket | null = null;
const initialForter =
parseCookieHeader(opts.seedCookie || "").find(
(cookie) => cookie.name.toLowerCase() === "fortertoken"
)?.value || "";
const startedAt = Date.now();
const onEvent = (method: string, params: Record<string, unknown>) => {
if (method === "Network.requestWillBeSent") {
if (capturedAccessToken) return;
const request = params.request as
{ url?: string; headers?: Record<string, string> } | undefined;
if (!request?.url || !request.url.includes(FIREFLY_3P_HOST_SUFFIX)) return;
@@ -264,6 +330,12 @@ async function captureViaCdp(opts: {
const auth = headers.Authorization || headers.authorization || headers.AUTHORIZATION || "";
const token = extractAdobeBearerTokenFromAuthorization(auth);
if (token) capturedAccessToken = token;
const arp =
headers["x-arp-session-id"] ||
headers["X-Arp-Session-Id"] ||
headers["X-ARP-SESSION-ID"] ||
"";
if (typeof arp === "string" && arp.trim()) capturedArpSessionId = arp.trim();
} else if (method === "Target.attachedToTarget") {
const sessionId = String(params.sessionId || "");
const targetInfo = params.targetInfo as { type?: string; targetId?: string } | undefined;
@@ -276,6 +348,20 @@ async function captureViaCdp(opts: {
try {
const browserWs = await openCdp(opts.browserWsUrl);
browserCdp = new CdpSocket(browserWs, onEvent);
const seed = parseCookieHeader(opts.seedCookie || "");
if (seed.length > 0) {
await browserCdp
.send("Storage.setCookies", {
cookies: seed.map(({ name, value }) => ({
name,
value,
url: FIREFLY_HOME_URL,
path: "/",
secure: true,
})),
})
.catch(() => undefined);
}
await browserCdp.send("Target.setDiscoverTargets", { discover: true }).catch(() => undefined);
await browserCdp
.send("Target.setAutoAttach", {
@@ -305,7 +391,12 @@ async function captureViaCdp(opts: {
const cdp = new CdpSocket(ws, onEvent);
pageSockets.set(t.id, cdp);
await cdp.send("Network.enable");
if (!t.url || t.url === "about:blank" || t.url.startsWith("chrome://")) {
if (
seed.length > 0 ||
!t.url ||
t.url === "about:blank" ||
t.url.startsWith("chrome://")
) {
await cdp.send("Page.enable").catch(() => undefined);
await cdp.send("Page.navigate", { url: FIREFLY_HOME_URL }).catch(() => undefined);
}
@@ -317,28 +408,52 @@ async function captureViaCdp(opts: {
// list may fail briefly while Chrome starts
}
if (capturedAccessToken) {
// Prefer cookies from any live page socket; fall back to empty.
for (const cdp of pageSockets.values()) {
if (!cdp.open) continue;
try {
const result = (await cdp.send("Network.getAllCookies")) as {
cookies?: CdpCookie[];
};
return {
accessToken: capturedAccessToken,
cookies: Array.isArray(result?.cookies) ? result.cookies : [],
};
} catch {
/* try next */
}
try {
const result = (await browserCdp.send("Storage.getCookies")) as {
cookies?: CdpCookie[];
};
if (Array.isArray(result?.cookies)) latestCookies = result.cookies;
} catch {
/* retry while Chrome is settling */
}
const accessToken = capturedAccessToken || String(opts.fallbackAccessToken || "").trim();
if (accessToken) {
if (!opts.waitForRiskRefresh) {
return {
accessToken,
cookies: latestCookies,
arpSessionId: capturedArpSessionId,
};
}
const elapsed = Date.now() - startedAt;
const forter = cookieValue(latestCookies, "forterToken");
const hasRiskCookies = Boolean(
forter &&
cookieValue(latestCookies, "ff_session_guid") &&
(cookieValue(latestCookies, "arkose") || cookieValue(latestCookies, "sherlockToken"))
);
const riskAdvanced = Boolean(forter && initialForter && forter !== initialForter);
if (hasRiskCookies && elapsed >= 8_000 && (riskAdvanced || elapsed >= 30_000)) {
return {
accessToken,
cookies: latestCookies,
arpSessionId: capturedArpSessionId,
};
}
return { accessToken: capturedAccessToken, cookies: [] };
}
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
}
const fallback = String(opts.fallbackAccessToken || "").trim();
if (fallback && latestCookies.length > 0) {
return {
accessToken: capturedAccessToken || fallback,
cookies: latestCookies,
arpSessionId: capturedArpSessionId,
};
}
throw new Error(
"Adobe Firefly sign-in timed out. Complete sign-in at firefly.adobe.com and trigger an action " +
"(open Generate) so the browser sends the Firefly request, then try again."
@@ -380,10 +495,13 @@ function killProcessTree(child: ChildProcess | null): void {
* Launch system Chrome/Edge at firefly.adobe.com, intercept firefly-3p
* Authorization Bearer via CDP, return JWT + useful cookies.
*/
export async function startAdobeFireflyBrowserLogin(
requestedTimeout?: unknown
): Promise<AdobeFireflyBrowserLoginResult> {
const timeout = clampAdobeFireflyLoginTimeout(requestedTimeout);
async function runAdobeFireflyCdpBrowser(opts: {
timeoutMs: number;
interactive: boolean;
seedCookie?: string;
accessToken?: string;
log?: AdobeFireflyBrowserLog;
}): Promise<AdobeFireflyBrowserLoginResult> {
const browserPath = resolveSystemBrowserExecutable();
if (!browserPath) {
return {
@@ -395,10 +513,9 @@ export async function startAdobeFireflyBrowserLogin(
};
}
let userDataDir: string | null = null;
let child: ChildProcess | null = null;
try {
userDataDir = mkdtempSync(join(tmpdir(), "omniroute-firefly-login-"));
const userDataDir = resolveAdobeFireflyBrowserProfileDir();
const port = await getFreeLoopbackPort();
const args = [
@@ -406,15 +523,14 @@ export async function startAdobeFireflyBrowserLogin(
`--user-data-dir=${userDataDir}`,
"--no-first-run",
"--no-default-browser-check",
"--disable-sync",
"--disable-background-networking",
...(opts.interactive ? [] : ["--window-position=-32000,-32000"]),
"--window-size=1280,800",
FIREFLY_HOME_URL,
];
child = spawn(browserPath, args, {
stdio: "ignore",
windowsHide: false,
windowsHide: !opts.interactive,
detached: false,
});
@@ -439,19 +555,28 @@ export async function startAdobeFireflyBrowserLogin(
captureViaCdp({
port,
browserWsUrl: webSocketDebuggerUrl,
timeoutMs: timeout,
timeoutMs: opts.timeoutMs,
fallbackAccessToken: opts.accessToken,
seedCookie: opts.seedCookie,
waitForRiskRefresh: !opts.interactive,
}),
earlyExit,
]);
const cookie = buildAdobeFireflyCookieHeader(captured.cookies);
const account = accountLabelFromAdobeJwt(captured.accessToken);
opts.log?.info?.(
"ADOBE-FIREFLY",
`CDP ${opts.interactive ? "sign-in" : "refresh"} captured durable session ` +
`(cookieCount=${captured.cookies.length}, arpLen=${captured.arpSessionId.length})`
);
return {
success: true,
credentials: {
accessToken: captured.accessToken,
...(cookie ? { cookie } : {}),
},
...(captured.arpSessionId ? { arpSessionId: captured.arpSessionId } : {}),
...(account ? { account } : {}),
};
} catch (error) {
@@ -462,14 +587,60 @@ export async function startAdobeFireflyBrowserLogin(
} finally {
killProcessTree(child);
child = null;
if (userDataDir) {
// Give Chrome a moment to release the profile directory.
await new Promise((r) => setTimeout(r, 300));
try {
rmSync(userDataDir, { recursive: true, force: true });
} catch {
// Profile may still be locked; temp cleaner will reclaim later.
}
}
}
}
export async function startAdobeFireflyBrowserLogin(
requestedTimeout?: unknown
): Promise<AdobeFireflyBrowserLoginResult> {
const run = cdpBrowserChain.then(() =>
runAdobeFireflyCdpBrowser({
timeoutMs: clampAdobeFireflyLoginTimeout(requestedTimeout),
interactive: true,
})
);
cdpBrowserChain = run.then(
() => undefined,
() => undefined
);
return run;
}
/** Packaged-safe background renewal. Reuses the durable sign-in profile; never imports Playwright. */
export async function refreshAdobeFireflyViaCdp(opts: {
cookie?: string;
accessToken?: string;
timeoutMs?: number;
log?: AdobeFireflyBrowserLog;
}): Promise<AdobeFireflyCdpRefreshResult | null> {
const run = cdpBrowserChain.then(async () => {
const result = await runAdobeFireflyCdpBrowser({
timeoutMs: Math.max(15_000, Math.min(120_000, Number(opts.timeoutMs) || 75_000)),
interactive: false,
seedCookie: opts.cookie,
accessToken: opts.accessToken,
log: opts.log,
});
const accessToken = String(result.credentials?.accessToken || "").trim();
const cookie = String(result.credentials?.cookie || "").trim();
if (!result.success || !accessToken || !cookie) return null;
return {
accessToken,
cookie,
arpSessionId: String(result.arpSessionId || "").trim(),
};
});
cdpBrowserChain = run.then(
() => undefined,
() => undefined
);
try {
return await run;
} catch (error) {
opts.log?.warn?.(
"ADOBE-FIREFLY",
`CDP background refresh failed: ${error instanceof Error ? error.message : String(error)}`
);
return null;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,8 @@
* 2) Rebuild x-arp-session-id from cookie pieces (ff_session_guid + arkose + forterToken)
* or pasted sherlockToken — never launch Chrome by default
* 3) Sticky working ARP across batch jobs + submit spacing (colligo rate-limit defense)
* 4) Optional Chrome warm ONLY when ADOBE_FIREFLY_BROWSER_REFRESH=1 (or mid-batch 408 recovery).
* Default Chrome mode is **off-screen headed** (Forter-safe). Headless is opt-in and often rejected.
* 4) Packaged-safe Chrome/CDP warm on stale risk state, JWT expiry, or 408 recovery.
* The durable browser profile holds Adobe SSO; Playwright is not required.
*/
import { createHash, randomUUID } from "node:crypto";
@@ -45,15 +45,22 @@ export interface AdobeFireflySessionResolveOpts {
credentials?: {
apiKey?: string;
accessToken?: string;
providerSpecificData?: { cookie?: unknown; access_token?: unknown; accessToken?: unknown } | null;
providerSpecificData?: {
cookie?: unknown;
access_token?: unknown;
accessToken?: unknown;
} | null;
} | null;
/** Force browser / cookie ARP rebuild (e.g. after HTTP 408). */
forceRefresh?: boolean;
/** Prefer minting a brand-new ARP (retry path). */
rotateArp?: boolean;
fetchImpl?: typeof fetch;
log?: { info?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void };
/** Disable Playwright refresh (tests / hosts without browsers). */
log?: {
info?: (...args: unknown[]) => void;
warn?: (...args: unknown[]) => void;
};
/** Disable durable CDP refresh (tests / hosts without Chrome or Edge). */
allowBrowserRefresh?: boolean;
}
@@ -77,19 +84,27 @@ let consecutiveAdobeSubmitSuccesses = 0;
/** Minimum gap between generate-async submits (ms). Prevents batch thrashing → 408. */
function minSubmitGapMs(): number {
if (process.env.ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS != null && process.env.ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS !== "") {
if (
process.env.ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS != null &&
process.env.ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS !== ""
) {
return Math.max(0, Number(process.env.ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS) || 0);
}
// Unit tests must not serialize multi-second gaps between cases that share the process-global gate.
if (process.env.NODE_ENV === "test" || process.env.VITEST || process.env.NODE_TEST_CONTEXT) return 0;
if (process.env.NODE_ENV === "test" || process.env.VITEST || process.env.NODE_TEST_CONTEXT)
return 0;
// Live colligo rejects thrash after a few generates even with sticky ARP — 12s default.
return 12_000;
}
/** Extra gap after every N successful submits (mid-batch death defense). */
function batchExtraGapMs(): number {
if (process.env.NODE_ENV === "test" || process.env.VITEST || process.env.NODE_TEST_CONTEXT) return 0;
if (consecutiveAdobeSubmitSuccesses > 0 && consecutiveAdobeSubmitSuccesses % BATCH_SUCCESS_COOLDOWN_EVERY === 0) {
if (process.env.NODE_ENV === "test" || process.env.VITEST || process.env.NODE_TEST_CONTEXT)
return 0;
if (
consecutiveAdobeSubmitSuccesses > 0 &&
consecutiveAdobeSubmitSuccesses % BATCH_SUCCESS_COOLDOWN_EVERY === 0
) {
return Number(process.env.ADOBE_FIREFLY_BATCH_EXTRA_GAP_MS || BATCH_SUCCESS_EXTRA_GAP_MS);
}
return 0;
@@ -133,14 +148,20 @@ function sessionFilePath(fingerprint: string): string {
}
export function fingerprintAdobeCredential(raw: string): string {
return createHash("sha256").update(String(raw || "").trim()).digest("hex").slice(0, 32);
return createHash("sha256")
.update(String(raw || "").trim())
.digest("hex")
.slice(0, 32);
}
/** Pull a single cookie value from a Cookie header / paste blob. */
export function getAdobeCookieValue(cookieOrBlob: string, name: string): string {
const raw = String(cookieOrBlob || "");
if (!raw || !name) return "";
const re = new RegExp(`(?:^|[;\\s\\n\\r])${name.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}=([^;\\s\\n\\r]+)`, "i");
const re = new RegExp(
`(?:^|[;\\s\\n\\r])${name.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}=([^;\\s\\n\\r]+)`,
"i"
);
const m = raw.match(re);
if (!m?.[1]) return "";
let v = m[1].trim().replace(/^["']|["']$/g, "");
@@ -260,9 +281,7 @@ export function buildAdobeArpSessionIdFromCookies(
if (!blob.trim()) return "";
const sid =
getAdobeCookieValue(blob, "ff_session_guid") ||
getAdobeCookieValue(blob, "sid") ||
"";
getAdobeCookieValue(blob, "ff_session_guid") || getAdobeCookieValue(blob, "sid") || "";
const ark = getAdobeCookieValue(blob, "arkose") || "";
const ftr =
normalizeAdobeForterToken(getAdobeCookieValue(blob, "forterToken")) ||
@@ -317,7 +336,11 @@ export function resolveAdobeArpSessionIdSmart(
if (rebuilt && extracted) {
const rebuiltFtr = (() => {
try {
const j = JSON.parse(Buffer.from(rebuilt + "=".repeat((4 - (rebuilt.length % 4)) % 4), "base64").toString("utf8")) as { ftr?: string };
const j = JSON.parse(
Buffer.from(rebuilt + "=".repeat((4 - (rebuilt.length % 4)) % 4), "base64").toString(
"utf8"
)
) as { ftr?: string };
return String(j.ftr || "");
} catch {
return "";
@@ -325,7 +348,11 @@ export function resolveAdobeArpSessionIdSmart(
})();
const extractedFtr = (() => {
try {
const j = JSON.parse(Buffer.from(extracted + "=".repeat((4 - (extracted.length % 4)) % 4), "base64").toString("utf8")) as { ftr?: string };
const j = JSON.parse(
Buffer.from(extracted + "=".repeat((4 - (extracted.length % 4)) % 4), "base64").toString(
"utf8"
)
) as { ftr?: string };
return String(j.ftr || "");
} catch {
return "";
@@ -359,7 +386,10 @@ export function mergeAdobeCookieHeaders(base: string, updates: string): string {
} catch {
/* keep */
}
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (/[\r\n\0]/.test(value)) continue;
@@ -372,7 +402,9 @@ export function mergeAdobeCookieHeaders(base: string, updates: string): string {
}
/** Serialize session back into a multi-line credential paste (JWT + Cookie). */
export function serializeAdobeFireflyCredential(session: Pick<AdobeFireflySession, "accessToken" | "cookie" | "arpSessionId">): string {
export function serializeAdobeFireflyCredential(
session: Pick<AdobeFireflySession, "accessToken" | "cookie" | "arpSessionId">
): string {
const lines: string[] = [];
if (session.accessToken) lines.push(session.accessToken.trim());
if (session.arpSessionId) lines.push(session.arpSessionId.trim());
@@ -438,12 +470,8 @@ function collectCredentialBlobs(
}
/**
* Browser warm for Firefly risk session (Forter/Arkose refresh).
*
* - Default Chrome mode: **off-screen headed** (parked at -32000,-32000). Colligo rejects
* headless Forter tokens; true headless only with ADOBE_FIREFLY_CHROME_HEADLESS=1.
* - Proactive use stays opt-in (ADOBE_FIREFLY_BROWSER_REFRESH=1).
* - Mid-batch 408 recovery may call with force=true so we mint a new ARP without re-paste.
* Browser warm for Firefly risk session (Forter/Arkose + IMS JWT refresh).
* Uses the same persistent pure-CDP profile as interactive sign-in, including in pkg builds.
* Never throws — returns null when unavailable.
*/
export async function refreshAdobeSessionViaBrowser(
@@ -456,31 +484,33 @@ export async function refreshAdobeSessionViaBrowser(
if (!adobeFireflyBrowserEnabled()) return null;
try {
const { warmAdobeFireflyViaChrome } = await import("./adobeFireflyChromeRuntime.ts");
const warmed = await warmAdobeFireflyViaChrome({
const { refreshAdobeFireflyViaCdp } = await import("./adobeFireflyBrowserLogin.ts");
const warmed = await refreshAdobeFireflyViaCdp({
cookie: session.cookie,
accessToken: session.accessToken,
log,
waitForLoginMs: 0, // never block on interactive login
// Default engine: warm the off-screen Chrome without requiring BROWSER_REFRESH=1.
allowWithoutEnvOptIn: true,
// Prove colligo accepts the ARP during recovery (in-page generate-async ping).
proveWithPing: opts?.proveWithPing ?? force,
timeoutMs: force ? 90_000 : 75_000,
});
if (!warmed) return null;
const nextCookie = mergeAdobeCookieHeaders(session.cookie, warmed.cookie || "");
const nextArp =
warmed.arpSessionId ||
buildAdobeArpSessionIdFromCookies(nextCookie) ||
extractAdobeArpSessionId(nextCookie);
if (!nextArp) return null;
const next: AdobeFireflySession = {
...session,
accessToken: warmed.accessToken || session.accessToken,
// Prefer warmed jar (fresh forter) over stale paste when keys collide.
cookie: mergeAdobeCookieHeaders(session.cookie, warmed.cookie || ""),
arpSessionId: warmed.arpSessionId,
tokenExpiresAt: warmed.tokenExpiresAt || session.tokenExpiresAt,
cookie: nextCookie,
arpSessionId: nextArp,
tokenExpiresAt: estimateAdobeTokenExpiry(warmed.accessToken || session.accessToken),
updatedAt: Date.now(),
source: "browser",
};
// When warm returns a fresher forter, prefer its cookie entirely for ARP rebuild pieces.
const warmFtr = extractAdobeForterTimestampMs(warmed.cookie || "");
const warmFtr = extractAdobeForterTimestampMs(nextCookie);
const baseFtr = extractAdobeForterTimestampMs(session.cookie || "");
if (warmFtr > baseFtr && warmed.cookie) {
next.cookie = mergeAdobeCookieHeaders(session.cookie, warmed.cookie);
@@ -490,13 +520,13 @@ export async function refreshAdobeSessionViaBrowser(
clearAdobeFireflyWorkingArp(session.fingerprint);
log?.info?.(
"ADOBE-FIREFLY",
`off-screen Chrome warm refreshed ARP (len=${next.arpSessionId.length}, force=${force}, forterTs=${warmFtr || 0})`
`durable CDP warm refreshed session (arpLen=${next.arpSessionId.length}, force=${force}, forterTs=${warmFtr || 0})`
);
return next;
} catch (err) {
log?.warn?.(
"ADOBE-FIREFLY",
`browser ARP refresh failed: ${err instanceof Error ? err.message : String(err)}`
`browser CDP session refresh failed: ${err instanceof Error ? err.message : String(err)}`
);
return null;
}
@@ -504,7 +534,7 @@ export async function refreshAdobeSessionViaBrowser(
/**
* Resolve a durable Firefly session from stored credentials.
* Caches in memory + DATA_DIR; rebuilds ARP from cookies; optionally warms via Playwright.
* Caches in memory + DATA_DIR; rebuilds ARP from cookies; optionally warms via durable CDP.
*/
export async function ensureAdobeFireflySession(
opts: AdobeFireflySessionResolveOpts
@@ -542,15 +572,22 @@ export async function ensureAdobeFireflySession(
break;
}
}
// Cookie-only paste: use short-lived memory cache JWT only (not a stale disk token alone)
// A browser-refreshed disk token must survive process restarts. Prefer it when the pasted
// token is absent or near expiry; the fingerprint still binds it to these credentials.
const pastedExpiresAt = accessToken ? estimateAdobeTokenExpiry(accessToken) : 0;
const cachedExpiresAt = cached?.accessToken
? cached.tokenExpiresAt > 0
? cached.tokenExpiresAt
: estimateAdobeTokenExpiry(cached.accessToken)
: 0;
if (
!accessToken &&
cached?.accessToken &&
isAdobeUserAccessToken(cached.accessToken) &&
sessionCache.has(fingerprint) &&
Date.now() - cached.updatedAt < 30 * 60_000
cachedExpiresAt - Date.now() >= JWT_REFRESH_SKEW_MS &&
(!accessToken || pastedExpiresAt - Date.now() < JWT_REFRESH_SKEW_MS)
) {
accessToken = cached.accessToken;
pasteHadUserJwt = false;
}
// Cookie blob
@@ -642,15 +679,16 @@ export async function ensureAdobeFireflySession(
// - no AdobeID user JWT yet (profile may hold one — cookie/JWT-free path), or
// - stale Forter risk session and no recently-accepted (sticky 2xx) ARP to reuse.
const jwtIsUser = isAdobeUserAccessToken(session.accessToken);
const jwtNeedsBrowserRefresh =
!jwtIsUser || session.tokenExpiresAt - Date.now() < JWT_REFRESH_SKEW_MS;
const forterAgeMs = getAdobeForterAgeMs(session.cookie);
const riskStale = !workingFresh && forterAgeMs > FORTER_PROACTIVE_WARM_MS;
const shouldWarm =
adobeFireflyBrowserEnabled() &&
opts.allowBrowserRefresh !== false &&
(opts.forceRefresh || opts.rotateArp || !jwtIsUser || riskStale);
// Need something to warm from: a cookie to seed, a signed-in profile (no user JWT yet),
// or an explicit refresh request.
const canWarm = Boolean(session.cookie) || !jwtIsUser || Boolean(opts.forceRefresh);
(opts.forceRefresh || opts.rotateArp || jwtNeedsBrowserRefresh || riskStale);
// A persistent signed-in browser profile can refresh even when the stored cookie is empty.
const canWarm = true;
if (shouldWarm && canWarm) {
const key = fingerprint;
let inflight = browserRefreshInFlight.get(key);
@@ -668,7 +706,7 @@ export async function ensureAdobeFireflySession(
session = { ...warmed, fingerprint };
opts.log?.info?.(
"ADOBE-FIREFLY",
`off-screen Chrome session warm applied (reason=${opts.forceRefresh ? "force" : opts.rotateArp ? "rotate" : !jwtIsUser ? "no-user-jwt" : "stale-forter"})`
`durable CDP session warm applied (reason=${opts.forceRefresh ? "force" : opts.rotateArp ? "rotate" : jwtNeedsBrowserRefresh ? "jwt-expiry" : "stale-forter"})`
);
}
}
@@ -690,12 +728,20 @@ export async function ensureAdobeFireflySession(
if (!isAdobeUserAccessToken(session.accessToken)) {
throw new AdobeFireflyError(
"Adobe Firefly is not signed in. On Providers → Adobe Firefly → Add Account (OAuth) choose " +
"\"Sign in with browser\" (fresh login window) or \"Paste JWT / Cookie\". After browser sign-in " +
'"Sign in with browser" (fresh login window) or "Paste JWT / Cookie". After browser sign-in ' +
"the app stores JWT+Cookie and keeps the risk session fresh automatically.",
401,
"not_signed_in"
);
}
if (session.tokenExpiresAt <= Date.now() + 30_000) {
throw new AdobeFireflyError(
"Adobe Firefly browser session expired and could not renew automatically. Re-open the " +
"Adobe Firefly account and sign in once so the durable browser profile can renew future JWTs.",
401,
"session_expired"
);
}
sessionCache.set(fingerprint, session);
saveDiskSession(session);
@@ -717,8 +763,16 @@ export async function rotateAdobeFireflySessionOnError(
log?: AdobeFireflySessionResolveOpts["log"];
/** Attempt index (1-based) for backoff policy. */
attempt?: number;
/** 401/403: bypass quiet ARP reuse and refresh JWT + cookies immediately. */
authFailure?: boolean;
}
): Promise<AdobeFireflySession> {
if (session.tokenExpiresAt <= 0) {
session = {
...session,
tokenExpiresAt: estimateAdobeTokenExpiry(session.accessToken),
};
}
const prevArp = session.arpSessionId;
const attempt = opts?.attempt ?? 1;
const forterTs = extractAdobeForterTimestampMs(session.cookie);
@@ -729,8 +783,12 @@ export async function rotateAdobeFireflySessionOnError(
// Attempt 12 when forter is not known-stale: keep same ARP (colligo short load / rate limit).
// Hours-old forter → skip quiet reuse and warm Chrome immediately (else all 5 attempts 408).
if (attempt <= 2 && !forterKnownStale) {
const same: AdobeFireflySession = { ...session, updatedAt: Date.now(), source: "cache" };
if (attempt <= 2 && !forterKnownStale && !opts?.authFailure) {
const same: AdobeFireflySession = {
...session,
updatedAt: Date.now(),
source: "cache",
};
sessionCache.set(session.fingerprint, same);
saveDiskSession(same);
opts?.log?.info?.(
@@ -741,16 +799,16 @@ export async function rotateAdobeFireflySessionOnError(
}
// Known-stale forter or attempt 3+: cookie rebuild is a no-op. Off-screen headed Chrome mints a
// fresh Forter/ARP (headless is rejected by colligo — see adobeFireflyChromeRuntime).
// fresh Forter/ARP (headless browsers are rejected by colligo, so CDP uses off-screen headed mode).
clearAdobeFireflyWorkingArp(session.fingerprint);
noteAdobeFireflySubmitFailure();
const tryBrowser =
opts?.tryBrowser !== false && process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "0";
if (tryBrowser && session.cookie) {
if (tryBrowser) {
opts?.log?.info?.(
"ADOBE-FIREFLY",
`408 recovery: off-screen Chrome warm (attempt=${attempt}, forterKnownStale=${forterKnownStale}, forterAgeMs=${forterAgeMs ?? "unknown"})`
`${opts?.authFailure ? "auth" : "408"} recovery: durable CDP warm (attempt=${attempt}, forterKnownStale=${forterKnownStale}, forterAgeMs=${forterAgeMs ?? "unknown"})`
);
const warmed = await refreshAdobeSessionViaBrowser(session, opts?.log, {
force: true,
@@ -762,13 +820,15 @@ export async function rotateAdobeFireflySessionOnError(
saveDiskSession(next);
opts?.log?.info?.(
"ADOBE-FIREFLY",
`408 recovery: Chrome warm done (arp changed=${warmed.arpSessionId !== prevArp}, forterTs=${extractAdobeForterTimestampMs(warmed.cookie)})`
`${opts?.authFailure ? "auth" : "408"} recovery: CDP warm done (arp changed=${warmed.arpSessionId !== prevArp}, forterTs=${extractAdobeForterTimestampMs(warmed.cookie)})`
);
return next;
}
}
const rebuilt = resolveAdobeArpSessionIdSmart(session.cookie, { rotate: true });
const rebuilt = resolveAdobeArpSessionIdSmart(session.cookie, {
rotate: true,
});
const next: AdobeFireflySession = {
...session,
arpSessionId: rebuilt && rebuilt !== prevArp ? rebuilt : session.arpSessionId,

View File

@@ -35,10 +35,16 @@ test("buildAdobeFireflyCookieHeader keeps only wanted pairs", () => {
{ name: "unrelated", value: "x" },
{ name: "sherlockToken", value: "s1" },
{ name: "forterToken", value: "f1" },
{ name: "arkose", value: "a1" },
{ name: "bad", value: "a;b" },
{ name: "ff_session_guid", value: "g1" },
{ name: "bfp", value: "b1" },
{ name: "fpjs", value: "j1" },
]);
assert.equal(header, "sherlockToken=s1; forterToken=f1; ff_session_guid=g1");
assert.equal(
header,
"sherlockToken=s1; forterToken=f1; arkose=a1; ff_session_guid=g1; bfp=b1; fpjs=j1"
);
assert.equal(buildAdobeFireflyCookieHeader([]), "");
});

View File

@@ -88,7 +88,10 @@ test("getExecutor(adobe-firefly) rejects chat completions", async () => {
assert.ok(executor);
const result = await executor.execute({
model: "adobe-firefly/nano-banana-pro",
body: { model: "adobe-firefly/nano-banana-pro", messages: [{ role: "user", content: "hi" }] },
body: {
model: "adobe-firefly/nano-banana-pro",
messages: [{ role: "user", content: "hi" }],
},
stream: false,
credentials: { apiKey: "tok" },
});
@@ -208,10 +211,7 @@ test("buildAdobeImagePayload attaches referenceBlobs like live adobe_atach_image
{ id: "2a4f1025-e0dc-4671-a11a-7dfd3c07bd94", usage: "general" },
{ id: "84c11d1a-e798-4300-a63e-c06504ca2068", usage: "general" },
]);
assert.equal(
(nano.generationMetadata as Record<string, unknown>).module,
"text2image"
);
assert.equal((nano.generationMetadata as Record<string, unknown>).module, "text2image");
const gpt = buildAdobeImagePayload({
prompt: "edit me",
@@ -260,7 +260,9 @@ test("parseAdobeImageSourceBytes + parseAdobeStorageUploadResponse", () => {
});
test("buildAdobeUploadHeaders uses image content-type not json", () => {
const h = buildAdobeUploadHeaders("tok", "image/png", { arpSessionId: "arp" });
const h = buildAdobeUploadHeaders("tok", "image/png", {
arpSessionId: "arp",
});
assert.equal(h["content-type"], "image/png");
assert.equal(h.Authorization, "Bearer tok");
assert.equal(h["x-api-key"], "clio-playground-web");
@@ -280,10 +282,10 @@ test("resolveAdobeSourceImageIds uploads data URLs then returns blob ids", async
const headers = init?.headers as Record<string, string>;
assert.match(String(headers["content-type"] || headers["Content-Type"] || ""), /image\//);
assert.ok(init?.body);
return new Response(
JSON.stringify({ images: [{ id: `blob-${uploadCalls}` }] }),
{ status: 200, headers: { "content-type": "application/json" } }
);
return new Response(JSON.stringify({ images: [{ id: `blob-${uploadCalls}` }] }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
throw new Error(`unexpected fetch ${u}`);
};
@@ -328,12 +330,16 @@ test("buildAdobeVideoPayload produces sora and veo shapes", () => {
});
test("extractAdobeResultLink prefers x-override-status-link then links.result", () => {
const headers = new Headers({ "x-override-status-link": "https://poll.example/job/1" });
const headers = new Headers({
"x-override-status-link": "https://poll.example/job/1",
});
assert.equal(extractAdobeResultLink(headers, {}), "https://poll.example/job/1");
const headers2 = new Headers();
assert.equal(
extractAdobeResultLink(headers2, { links: { result: { href: "https://poll.example/job/2" } } }),
extractAdobeResultLink(headers2, {
links: { result: { href: "https://poll.example/job/2" } },
}),
"https://poll.example/job/2"
);
});
@@ -387,8 +393,7 @@ test("buildAdobeSubmitNonce is sha256(user_id + prompt[:256])", async () => {
type: "access_token",
client_id: "clio-playground-web",
})
)
.toString("base64url");
).toString("base64url");
const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url");
const token = `${header}.${payload}.${"x".repeat(40)}`;
// Pad token length for looksLikeAdobeJwt (>=80)
@@ -449,8 +454,7 @@ test("buildAdobeSubmitNonce is sha256(user_id + prompt[:256])", async () => {
});
test("normalizeAdobePollUrl rewrites firefly-epo jobs/result to BKS", () => {
const raw =
"https://firefly-epo855232.adobe.io/jobs/result/4ae9fd2a-0864-46dd-9834-cfc16e91faa6";
const raw = "https://firefly-epo855232.adobe.io/jobs/result/4ae9fd2a-0864-46dd-9834-cfc16e91faa6";
const out = normalizeAdobePollUrl(raw);
assert.match(out, /^https:\/\/bks-epo8552\.adobe\.io\/v2\/jobs\/result\/4ae9fd2a/);
assert.match(out, /host=firefly-epo855232\.adobe\.io/);
@@ -464,7 +468,9 @@ test("parseAdobeCreditsBalance maps total + free/plan buckets", () => {
},
credits: {
firefly_free_credit: { quota: { total: 10, used: 0, available: 10 } },
firefly_plan_credit: { quota: { total: 10000, used: 10, available: 9990 } },
firefly_plan_credit: {
quota: { total: 10000, used: 10, available: 9990 },
},
},
});
assert.equal(bal.total, 10010);
@@ -532,9 +538,9 @@ test("fallback catalog has image and video entries from get_models capture", ()
test("extractAdobeAccountIdFromToken reads user_id claim", () => {
// {"user_id":"0EB@AdobeID"} base64url
const payload = Buffer.from(JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token" })).toString(
"base64url"
);
const payload = Buffer.from(
JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token" })
).toString("base64url");
const jwt = `eyJhbGciOiJub25lIn0.${payload}.sig`;
assert.equal(extractAdobeAccountIdFromToken(jwt), "0EB@AdobeID");
});
@@ -571,7 +577,11 @@ function userImsJwt(userId = "0EB@AdobeID"): string {
return (
`eyJhbGciOiJSUzI1NiJ9.` +
Buffer.from(
JSON.stringify({ user_id: userId, type: "access_token", client_id: "clio-playground-web" })
JSON.stringify({
user_id: userId,
type: "access_token",
client_id: "clio-playground-web",
})
).toString("base64url") +
`.` +
"sig".padEnd(40, "x")
@@ -652,7 +662,10 @@ test("handleAdobeFireflyImageGeneration uploads refs and submits referenceBlobs"
assert.equal(result.success, true);
assert.equal(uploadCalls, 1);
assert.ok(sawGenerateBody);
const refs = sawGenerateBody!.referenceBlobs as Array<{ id: string; usage: string }>;
const refs = sawGenerateBody!.referenceBlobs as Array<{
id: string;
usage: string;
}>;
assert.deepEqual(refs, [{ id: "ref-blob-1", usage: "general" }]);
});
@@ -719,13 +732,19 @@ test("guest JWT without AdobeID is detected", () => {
const emptyPayload = Buffer.from("{}").toString("base64url");
const guestJwt = `eyJhbGciOiJub25lIn0.${emptyPayload}.sig`;
// Pad to lookLikeAdobeJwt length if needed
const longGuest = `eyJhbGciOiJSUzI1NiJ9.${Buffer.from(JSON.stringify({ client_id: "clio-playground-web" })).toString("base64url")}.` + "x".repeat(40);
const longGuest =
`eyJhbGciOiJSUzI1NiJ9.${Buffer.from(JSON.stringify({ client_id: "clio-playground-web" })).toString("base64url")}.` +
"x".repeat(40);
assert.equal(isAdobeGuestAccessToken(longGuest), true);
const userJwt =
`eyJhbGciOiJSUzI1NiJ9.` +
Buffer.from(JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token", client_id: "clio-playground-web" })).toString(
"base64url"
) +
Buffer.from(
JSON.stringify({
user_id: "0EB@AdobeID",
type: "access_token",
client_id: "clio-playground-web",
})
).toString("base64url") +
`.` +
"y".repeat(40);
assert.equal(isAdobeGuestAccessToken(userJwt), false);
@@ -802,9 +821,8 @@ test("extractAdobeArpSessionId recovers JWT+ARP joined by space (PasswordBox man
});
test("extractAdobeArpSessionId does not pick aux_sid over sherlockToken", async () => {
const { extractAdobeArpSessionId, isValidAdobeArpSessionId } = await import(
"../../open-sse/services/adobeFireflyClient.ts"
);
const { extractAdobeArpSessionId, isValidAdobeArpSessionId } =
await import("../../open-sse/services/adobeFireflyClient.ts");
const { ADOBE_FIREFLY_FTR_MAGIC } = await import("../../open-sse/services/adobeFireflyClient.ts");
const realArp = Buffer.from(
JSON.stringify({
@@ -879,7 +897,13 @@ test("rebuild ARP from cookie components (forter+arkose+sid)", async () => {
});
test("isAdobeTransientSubmitError detects 408 system under load", () => {
assert.equal(isAdobeTransientSubmitError(408, '{"error_code":"timeout_error","message":"system under load"}'), true);
assert.equal(
isAdobeTransientSubmitError(
408,
'{"error_code":"timeout_error","message":"system under load"}'
),
true
);
assert.equal(isAdobeTransientSubmitError(429, "rate"), true);
assert.equal(isAdobeTransientSubmitError(400, "bad request"), false);
assert.ok(generateAdobeNonce().length === 64);
@@ -918,9 +942,8 @@ test("resolveAdobeImageModel maps gpt-image-2 alias", async () => {
});
test("image submit retries on 408 then succeeds", async () => {
const { __resetAdobeFireflySessionCacheForTests } = await import(
"../../open-sse/services/adobeFireflySession.ts"
);
const { __resetAdobeFireflySessionCacheForTests } =
await import("../../open-sse/services/adobeFireflySession.ts");
__resetAdobeFireflySessionCacheForTests();
let submits = 0;
@@ -930,13 +953,12 @@ test("image submit retries on 408 then succeeds", async () => {
if (u.includes("generate-async")) {
submits += 1;
if (submits < 3) {
return jsonResponse(408, { error_code: "timeout_error", message: "system under load" });
return jsonResponse(408, {
error_code: "timeout_error",
message: "system under load",
});
}
return jsonResponse(
200,
{ links: { result: { href: "https://poll.example/job/r1" } } },
{}
);
return jsonResponse(200, { links: { result: { href: "https://poll.example/job/r1" } } }, {});
}
if (u.includes("poll.example")) {
return jsonResponse(200, {
@@ -1005,8 +1027,7 @@ test("rotateAdobeFireflySessionOnError: attempt1-2 reuse sticky; attempt3 keeps
__resetAdobeFireflySessionCacheForTests();
const ftr = `aa_${Date.now()}${ADOBE_FIREFLY_FTR_MAGIC}_x=-1-v2_tt`;
const cookie =
`ff_session_guid=sid-1; arkose=ark-1; forterToken=${encodeURIComponent(ftr)}`;
const cookie = `ff_session_guid=sid-1; arkose=ark-1; forterToken=${encodeURIComponent(ftr)}`;
const rebuilt = buildAdobeArpSessionIdFromCookies(cookie);
assert.ok(rebuilt);
@@ -1039,19 +1060,33 @@ test("rotateAdobeFireflySessionOnError: attempt1-2 reuse sticky; attempt3 keeps
);
// attempt 3 without browser: cookie rebuild (identical forter → same ARP)
assert.ok(a3.arpSessionId, "attempt 3 still yields an ARP");
assert.equal(a3.arpSessionId, rebuilt, "identical cookie rebuild keeps ARP (no synthetic thrash)");
assert.equal(
a3.arpSessionId,
rebuilt,
"identical cookie rebuild keeps ARP (no synthetic thrash)"
);
const auth = await rotateAdobeFireflySessionOnError(
{ ...base, arpSessionId: rebuilt!, tokenExpiresAt: 0 },
{ attempt: 1, authFailure: true, tryBrowser: false }
);
assert.equal(auth.source, "rebuild", "auth failures must bypass quiet sticky reuse");
assert.ok(auth.tokenExpiresAt > Date.now(), "retry must repair a missing token expiry");
});
test("adobeFireflyGenerateImage cookie path exchanges IMS token first", async () => {
const { __resetAdobeFireflySessionCacheForTests } = await import(
"../../open-sse/services/adobeFireflySession.ts"
);
const { __resetAdobeFireflySessionCacheForTests } =
await import("../../open-sse/services/adobeFireflySession.ts");
__resetAdobeFireflySessionCacheForTests();
const userTok =
`eyJhbGciOiJSUzI1NiJ9.` +
Buffer.from(
JSON.stringify({ user_id: "0EB@AdobeID", type: "access_token", client_id: "clio-playground-web" })
JSON.stringify({
user_id: "0EB@AdobeID",
type: "access_token",
client_id: "clio-playground-web",
})
).toString("base64url") +
`.` +
"s".repeat(40);
@@ -1061,7 +1096,10 @@ test("adobeFireflyGenerateImage cookie path exchanges IMS token first", async ()
if (String(url).includes("ims/check")) {
assert.equal(init?.method, "POST");
// Authenticated exchange (guest_allowed=false)
return jsonResponse(200, { access_token: userTok, account_type: "type1" });
return jsonResponse(200, {
access_token: userTok,
account_type: "type1",
});
}
if (String(url).includes("generate-async")) {
const auth =
@@ -1073,11 +1111,7 @@ test("adobeFireflyGenerateImage cookie path exchanges IMS token first", async ()
? (init.headers as Record<string, string>).Authorization
: auth;
assert.equal(headerAuth, `Bearer ${userTok}`);
return jsonResponse(
200,
{},
{ "x-override-status-link": "https://poll.example/job/c1" }
);
return jsonResponse(200, {}, { "x-override-status-link": "https://poll.example/job/c1" });
}
if (String(url).includes("poll.example")) {
return jsonResponse(200, {