mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 09:42:15 +03:00
* fix(adobe-firefly): open browser sign-in and resolve provider slug in /login
POST /api/providers/[id]/login passed the connection DB id to
inAppLoginService.startLogin, but that service looks up the provider by
slug in TOKEN_EXTRACTION_CONFIGS. The lookup always missed and returned
"No extraction config" without launching a browser — so the VibeProxy
"Sign in" button for Adobe Firefly (and every other web-cookie provider)
never opened a browser.
Adobe Firefly additionally had no extraction config because its IMS JWT
is never in cookies/localStorage — it only rides on the Authorization:
Bearer header of firefly-3p.ff.adobe.io XHRs.
- Resolve the provider slug from the connection row and pass the slug
(not the DB id) to inAppLoginService.startLogin.
- Add open-sse/services/adobeFireflyBrowserLogin.ts: a Playwright
service that launches a visible browser at firefly.adobe.com and
intercepts firefly-3p requests to capture the IMS JWT + sherlockToken
cookie. Wire it into the /login route for the adobe-firefly slug.
- Fix latent bug: updateProviderConnection reads camelCase keys
(apiKey, providerSpecificData), so the previous snake_case call never
persisted extracted credentials.
* fix(adobe-firefly): open browser sign-in and resolve provider slug in /login
POST /api/providers/[id]/login passed the connection DB id to
inAppLoginService.startLogin, but TOKEN_EXTRACTION_CONFIGS is keyed by
provider slug — so browser login never launched for web-cookie providers.
Adobe Firefly also cannot use cookie extraction: the IMS JWT only appears
on Authorization headers to firefly-3p.ff.adobe.io. Add a dedicated
Playwright interceptor and persist credentials with camelCase keys that
updateProviderConnection actually reads.
* fix(adobe-firefly): use system Chrome/Edge CDP for browser sign-in
Playwright is not available inside the pkg-packaged VibeProxyServices.exe,
so import('playwright') always failed with 'Playwright not installed' and
never opened a window. Launch Chrome/Edge with --remote-debugging-port and
capture the firefly-3p Authorization Bearer via pure CDP WebSocket instead.
* fix(adobe-firefly): live x-arp-session-id / Arkose wire (stop 408 under load)
Browser generate-async requires x-arp-session-id as base64({sid,ark,ftr}) with a
real Arkose blob (sherlockToken). JWT alone frequently returns colligo HTTP 408
system under load while credits still work.
- Match live ftr magic __UDF43-m4_31ck + Arkose pk in synthetic ARP fallback
- Ranked extract of sherlockToken / x-arp from Cookie, HAR, fetch() paste, and
space-joined JWT+ARP (PasswordBox newline collapse)
- Reuse one ARP for storage upload + generate-async
- Clearer 408 errors when browser ARP is missing vs stale
- Unit suite 42/42
* fix(adobe-firefly): durable session ARP rebuild and aux_sid false-positive
Rebuild x-arp-session-id from forterToken/arkose/ff_session_guid instead of
ranking long Cookie pairs (e.g. aux_sid=…) as opaque ARP, which caused colligo
HTTP 408. Cache IMS JWT + cookie sessions, rotate ARP on 408 retries, and keep
Playwright warm-up opt-in only (headless Forter is rejected).
Also expand synthetic ARP shape with bfp/fpjs to match live successful captures.
* fix(adobe-firefly): durable session, off-screen Chrome recovery, browser sign-in
Rebuild x-arp-session-id from Cookie pieces (sid/ark/forter) so aux_sid is never
sent as ARP. Sticky ARP + submit spacing reduce mid-batch colligo 408 thrash.
Add optional managed Chrome warm (off-screen headed by default; Forter rejects
headless) and POST /api/providers/{id}/login browser sign-in that returns JWT+Cookie
after a fresh SSO. Visible sign-in resets off-screen window placement and clears
prior Adobe session when adding another account.
* fix(adobe-firefly): renew sessions through durable CDP
* fix(adobe-firefly): isolate browser sessions per account
* fix(adobe-firefly): make account login fresh and deterministic
* chore(adobe-firefly): remove obsolete browser fallback
* docs(adobe-firefly): document renewal controls
* fix(adobe-firefly): harden CDP warm, risk session, and browser sign-in
Stop colligo 408 thrash from stale Forter and frozen Google login during
Sign in with browser:
- CDP warm: clear Firefly origin storage + risk cookies (keep SSO); require
forter age under 10 minutes on loop and timeout paths; dual CDP queues;
await Runtime.runIfWaitingForDebugger; profile-lock launch retries
- Session: connectionId fingerprint; write-back JWT+Cookie; warm-fail
cooldown; fail closed risk_session_stale when forter is known-stale
- Client: submit gate around generate-async; max 2 attempts when forter
known-stale; poll 401 one refresh; pass sessionBrowserKey through handlers
- Login route: pure system Chrome/Edge CDP only; camelCase credential persist
- Unit: browser-login + firefly suites green (60)
---------
Co-authored-by: artickc <artur1992123@mail.ru>
This commit is contained in:
committed by
GitHub
parent
5eba045175
commit
57fb90d734
13
.env.example
13
.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
|
||||
|
||||
@@ -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 <Link> 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 <select> + conditional fallback-backend <select> 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."
|
||||
}
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
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,
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1200
open-sse/services/adobeFireflyChromeRuntime.ts
Normal file
1200
open-sse/services/adobeFireflyChromeRuntime.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1002
open-sse/services/adobeFireflySession.ts
Normal file
1002
open-sse/services/adobeFireflySession.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,142 @@ function resolveProviderSlug(connection: Record<string, unknown> | 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<NextResponse> {
|
||||
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<string, unknown>);
|
||||
|
||||
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<string, string> = {};
|
||||
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) {
|
||||
|
||||
@@ -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. " +
|
||||
|
||||
@@ -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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, Record<string, unknown>>).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<string, string> = {}) {
|
||||
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 =
|
||||
|
||||
Reference in New Issue
Block a user