mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 18:22:48 +03:00
fix(adobe-firefly): renew sessions through durable CDP
This commit is contained in:
@@ -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
@@ -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 1–2 when forter is not known-stale: keep same ARP (colligo short load / rate limit).
|
||||
// Hours-old forter → skip quiet reuse and warm Chrome immediately (else all 5 attempts 408).
|
||||
if (attempt <= 2 && !forterKnownStale) {
|
||||
const same: AdobeFireflySession = { ...session, updatedAt: Date.now(), source: "cache" };
|
||||
if (attempt <= 2 && !forterKnownStale && !opts?.authFailure) {
|
||||
const same: AdobeFireflySession = {
|
||||
...session,
|
||||
updatedAt: Date.now(),
|
||||
source: "cache",
|
||||
};
|
||||
sessionCache.set(session.fingerprint, same);
|
||||
saveDiskSession(same);
|
||||
opts?.log?.info?.(
|
||||
@@ -741,16 +799,16 @@ export async function rotateAdobeFireflySessionOnError(
|
||||
}
|
||||
|
||||
// Known-stale forter or attempt 3+: cookie rebuild is a no-op. Off-screen headed Chrome mints a
|
||||
// fresh Forter/ARP (headless is rejected by colligo — see adobeFireflyChromeRuntime).
|
||||
// fresh Forter/ARP (headless browsers are rejected by colligo, so CDP uses off-screen headed mode).
|
||||
clearAdobeFireflyWorkingArp(session.fingerprint);
|
||||
noteAdobeFireflySubmitFailure();
|
||||
|
||||
const tryBrowser =
|
||||
opts?.tryBrowser !== false && process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "0";
|
||||
if (tryBrowser && session.cookie) {
|
||||
if (tryBrowser) {
|
||||
opts?.log?.info?.(
|
||||
"ADOBE-FIREFLY",
|
||||
`408 recovery: off-screen Chrome warm (attempt=${attempt}, forterKnownStale=${forterKnownStale}, forterAgeMs=${forterAgeMs ?? "unknown"})`
|
||||
`${opts?.authFailure ? "auth" : "408"} recovery: durable CDP warm (attempt=${attempt}, forterKnownStale=${forterKnownStale}, forterAgeMs=${forterAgeMs ?? "unknown"})`
|
||||
);
|
||||
const warmed = await refreshAdobeSessionViaBrowser(session, opts?.log, {
|
||||
force: true,
|
||||
@@ -762,13 +820,15 @@ export async function rotateAdobeFireflySessionOnError(
|
||||
saveDiskSession(next);
|
||||
opts?.log?.info?.(
|
||||
"ADOBE-FIREFLY",
|
||||
`408 recovery: Chrome warm done (arp changed=${warmed.arpSessionId !== prevArp}, forterTs=${extractAdobeForterTimestampMs(warmed.cookie)})`
|
||||
`${opts?.authFailure ? "auth" : "408"} recovery: CDP warm done (arp changed=${warmed.arpSessionId !== prevArp}, forterTs=${extractAdobeForterTimestampMs(warmed.cookie)})`
|
||||
);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
const rebuilt = resolveAdobeArpSessionIdSmart(session.cookie, { rotate: true });
|
||||
const rebuilt = resolveAdobeArpSessionIdSmart(session.cookie, {
|
||||
rotate: true,
|
||||
});
|
||||
const next: AdobeFireflySession = {
|
||||
...session,
|
||||
arpSessionId: rebuilt && rebuilt !== prevArp ? rebuilt : session.arpSessionId,
|
||||
|
||||
@@ -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([]), "");
|
||||
});
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user