Releases: dickymuliafiqri/firefly
Releases · dickymuliafiqri/firefly
Release list
Release v1.25.1
Release v1.25.1
See CHANGELOG.md for full change history.
Release v1.25.0
Added
- Persistent Cross-View Model Cache (
frontend/src/services/modelCache.ts,frontend/src/components/upstream/ModelsTab.tsx,frontend/src/pages/ModelsPage.tsx):- New Zustand
persiststore (firefly-model-cacheinlocalStorage) mapping upstream name to its discovered model list, so a model discovery performed once in the Upstream Editor is reused by the Models page (and every later editor session) instead of re-hitting the upstream. ModelsTabwrites fetched models into the cache and restores them when the editor switches upstream with an empty list (latency shown only for live fetches, not cache restores).- Exported helpers
getCachedModels/setCachedModels/getAllCachedModelspluspickOptimalProbeModel, which reuses the archived picker heuristics (free->flash->mini/haiku/chat-> first non-embedding model) and is applied automatically when models are discovered with no probe model selected yet. ModelFormon the Models page reads the same cache for its upstream-model dropdown and can refresh it in place via a Fetch list button (POST /api/upstreams/modelsagainst the selected upstream, first credential as fallback key).
- New Zustand
- Bounded-Concurrency Model Health Checks (
frontend/src/components/upstream/ModelsTab.tsx):Check all healthnow runs a worker pool instead of probing models strictly in sequence. Concurrency is operator-configurable from a new Health Check & Probe Configuration card (default 5, clamped to 1-20) and surfaced on the button (Check all health (5x)).- A live progress line reports
completed / total (healthy, failed)while the pool runs; the existing generation counter still invalidates in-flight workers on Stop or upstream switch.
Changed
- Probe Model Moved to a Dropdown in the Models Tab (
frontend/src/components/upstream/GeneralTab.tsx,frontend/src/components/upstream/ModelsTab.tsx,frontend/src/pages/UpstreamEditorPage.tsx):- The free-text
Probe modelfield is gone from General & Network;probeModelis now dedicated editor state loaded fromtarget.probe_modeland edited through a<select>in the Models tab, populated from discovered (or cached) models. - The dropdown stays disabled with an explanatory placeholder until models are fetched or restored from cache, so the probe model can no longer reference a model this upstream does not serve; the active probe row is badged
PROBEin the discovery table. KeysTabkeeps receiving the same value for per-key inference probes — request behavior is unchanged, only the input surface moved.
- The free-text
- Resilience Error Threshold Defaults to Disabled (
frontend/src/components/upstream/ResilienceTab.tsx,frontend/src/pages/UpstreamEditorPage.tsx):keyErrorThresholdnow defaults to0(new upstreams and stored upstreams without the field), matching the backend'sthreshold > 0guard inupstream.HandleKeyOutcome, so no consecutive-error action fires unless the operator opts in. The input ismin="0"with hint text and sanitizes negative/NaN input to0; granular rule thresholds are clamped tomin 1.
- Models Page Form Inputs (
frontend/src/pages/ModelsPage.tsx):ModelForm: the upstream-model field becomes a select of cached models with a Custom input / Select from list toggle (and the Fetch list refresh), falling back to plain text while no cache exists.ComboForm: the comma-delimited member string is replaced by an ordered chip list — numbered rows with move up/down, remove, and the resolved upstream — plus clickable catalog-model chips for adding; the submit guard requires at least one selected member instead of a non-empty string.
Release v1.24.1
Fixed
- Editing a Tenant Deleted It (
internal/storage/turso/store.go,internal/storage/turso/store_tenant_roundtrip_test.go):- Saving any change to a tenant from the dashboard (Tenants → edit → Save changes) removed that tenant from the list instead of updating it, on Turso-backed deployments.
- Root cause:
Store.SaveSettingsindexes every stored row under two identities inexistingTenants(api_keyandkey_hash), but only the payload's own identity was registered inactiveTenants— and only when the payload suppliedkey_hash. The hash the store derives from a plaintextsk-gw-…key (line-matched againstexistingTenantsa few lines later) was never registered. The dashboard's edit form rebuilds the tenant entry from its own fields, so the payload carriesapi_keybut nokey_hash: the row was updated and then immediately removed by the "delete tenants absent from an authoritative payload" sweep, which matched the row'skey_hashentry that had never been marked active. Creating a tenant was unaffected becausehandleCreateTenantfillsKeyHashin before persisting. - The derived hash is now registered in
activeTenantsalongside the payload key, so every identity a row is indexed under counts as present before the sweep runs. Deployments without Turso were never affected: the file writer persists the whole tenant list and never deletes by omitting. - The authoritative delete itself is unchanged:
TestSaveSettings_TenantRemovalStillHonoredasserts that a payload genuinely omitting a tenant still removes it, andTestSaveSettings_TenantEditKeepsSiblingsasserts that editing one tenant leaves the others (and their credentials) untouched.TestSaveSettings_TenantEditRoundTripreproduces the dashboard round trip (load → edit → re-save withmanage_tenants: true) and reportedtenants now: []before the fix.
Release v1.24.0
Fixed
-
Stateless Dashboard Sessions (
internal/security/auth/session_token.go,internal/security/auth/auth_manager.go,internal/server/auth_handlers.go):- The dashboard no longer signs the operator out a few seconds after login on horizontally scaled or ephemeral hosts (Railway with more than one replica, Vercel/Lambda cold starts, container restarts, zero-downtime instance swaps).
- Root cause: a session was a random string whose record lived only in the issuing process's in-memory map (
Manager.sessions). The next request — the dashboard polls/api/telemetryevery 2 seconds — was routinely served by a process that had never seen that token,Manager.ValidateTokenreturned false,/api/auth/verifyanswered 401, andfrontend/src/services/api.tstreated that as a dead session: it cleared the token fromlocalStorageand redirected to#login. The 24-hour TTL was never the problem; the token was simply unrecognized by the process that received it. - The token now carries its own signed claims (
sub,iat,exp,jti, and the credential generationep) and is authenticated with HMAC-SHA256 —ff_sess_<base64url(payload)>.<base64url(tag)>— so any instance holding the same signing secret verifies it with no shared memory. Validity is computed, not looked up, which makes the TTL apply everywhere and survive restarts. - Signing-secret resolution, in order:
$FIREFLY_SESSION_SECRET(recommended for serverless and multi-instance hosts — the only source that survives a cold start on an ephemeral filesystem), thensession_secretinconfigs/auth.json, then a freshly generated value persisted there. Without an environment secret, a host that regenerates its config directory mints a new key on every start and invalidates the browser's token; hence the documented recommendation. - Revocation model: stateless tokens have no per-token server record, so signing out — or changing the password — advances the persisted
password_epoch, retiring every token issued earlier. Sign-out is therefore dashboard-wide (the correct semantic for a single-operator console, and what makes sign-out meaningful for tokens the server never stored) and is honored by any instance reading the sameauth.json. Where no shared state exists, rotate$FIREFLY_SESSION_SECRETto revoke fleet-wide at the next start; the 24-hour TTL remains the hard bound. - Safety: the tag is compared with
hmac.Equal(constant time), and the parser fails closed on malformed, forged, expired, or superseded-generation input. The static admin-token path is untouched, so machine callers keep working exactly as before.RevokeSessiononly advances the generation for genuine session tokens, so a stray bearer value cannot force a write (and a disk write) on every logout call. - Tests:
TestSessionToken_ValidAcrossInstances(the regression — a session issued by one manager validates on another with no shared session record),TestSessionToken_SurvivesColdStartWithEnvSecret,TestSessionToken_RotatedSecretRetiresSessions,TestSessionToken_RejectsMalformedAndForged(10 malformed/tampered cases, including a legacy random token),TestSessionToken_ExpiryIsEnforced,TestSessionToken_EpochMismatchRejected,TestAuthManager_PasswordChangeRetiresSessionsPersistently, plusTestAuthManager_RevokeSession/TestAuthManager_ConcurrentAccessupdated for the generation-wide sign-out semantics. - Upgrade note: tokens issued by an older build are unsigned and therefore invalid after upgrading, so each browser signs in once more. No frontend change is required — the token stays an opaque string in
localStorage.
-
nullKey Slots Crashed the Upstreams Page (internal/server/telemetry.go,frontend/src/pages/UpstreamsPage.tsx,frontend/src/components/upstream/UpstreamDrawer.tsx,frontend/src/services/schema.ts):GET /api/telemetryserialized an upstream with zero credentials (created with just a name andbase_url, so itsKeyRingholds no slots) as"slots": null— a Go nil slice marshals to JSONnull, not[]. The dashboard'suseMemothen called.filteronnull(Uncaught TypeError: can't access property "filter", P.slots is null), which unmounted the whole React tree and left the production dashboard as a bare background with no sidebar or navbar.- The handler now normalizes every collection it renders (
slots,models,upstreams,tenants_usage) to[]at the JSON boundary; regression testTestTelemetry_UpstreamWithoutSlots(nil KeyRing and empty KeyRing both assert no"slots":nullreaches the wire). - Defense in depth:
UpstreamTelemetryDTO.slotsis typed nullable so TypeScript forces consumers to normalize (?? []) —UpstreamsPageandUpstreamDrawernow degrade a keyless upstream to an empty key table / "0 keys" badge. - Dashboard Error Boundary (
frontend/src/components/ui/ErrorBoundary.tsx,App.tsx,main.tsx): the app previously had no error boundary anywhere, so any render exception blanked the entire shell. A page-level boundary (keyed by page id, insideAppShell) keeps sidebar/navbar/toasts alive and offers "Try again" / "Reload page"; a root boundary inmain.tsxis the last resort if the shell itself throws.
Release v1.23.1
Security
- Upgraded
vite5.4.21 → 6.4.3 infrontend/andarchive/frontend/, closing every open Dependabot alert (6 total: 2 high, 4 moderate):- GHSA-fx2h-pf6j-xcff (high):
server.fs.denybypass on Windows alternate paths. - GHSA-4w7w-66w2-5vf9 (moderate): path traversal in optimized deps
.maphandling. - GHSA-v6wh-96g9-6wx3 (moderate): launch-editor NTLMv2 hash disclosure via UNC path handling on Windows.
- GHSA-67mh-4wv8-2f99 (moderate): esbuild dev-server request smuggling (
esbuildmoved from 0.21.5 to the^0.25.0line bundled by vite 6.4.3). @vitejs/plugin-react@4.7.0already declares vite 6 in its peer range, so only vite itself was bumped.bun auditis now clean for both manifests,bun run build(typecheck + vite build) passes, andfrontend/dist/index.htmlis intact for//go:embed all:dist.
- GHSA-fx2h-pf6j-xcff (high):
Release v1.23.0
Added
- System Prompt Guard (
internal/domain/tokensaver.go,internal/config/dto.go,internal/config/builder.go,internal/tokensaver/guard.go,internal/server/handlers.go,internal/server/settings.go,frontend/src/pages/SettingsPage.tsx,frontend/src/services/schema.ts):- New operator-configurable system prompt (
token_saver.system_prompt) injected into every/v1/chat/completionsrequest before forwarding, designed to suppress promotional messages and group invites inserted by third-party API sellers. - Placement appends the directive to the tail of the existing system message (or inserts a new system message at index 0 when absent / for structured multimodal content), so the operator prohibition carries the last word within the system block; injection is idempotent (a directive already present is never duplicated).
- Applied independently of the Token Saver master switch so the guard stays active when compression is off, and to the OpenAI-shaped body before
adapter.Forwardso every protocol (openai, anthropic, ...) is covered. - Settings API rejects
system_promptpayloads abovedomain.MaxSystemPromptChars(4,000) with HTTP 400; the token estimate (tokensIn) is recomputed once after any body rewrite. - Dashboard: textarea with character counter and explicit Save/Reset in Settings → Token Saver → System prompt guard; persists to
tokensaver.jsonand the Tursotoken_saversettings key via the existing whole-DTO marshaling. - Tests:
internal/tokensaver/guard_test.go(append, insert, idempotency, pre-existing directive, structured content, no-op cases),TestForwardEndpointInjectsSystemPromptGuard(forwarded-body coverage incl. master-switch-off), extendedTestSettingsTokenSaverPersistence, andTestSettingsRejectsOversizedSystemPrompt.
- New operator-configurable system prompt (
Fixed
- Model/Combo/Tenant/Upstream deletion silently ignored by the Turso store (
frontend/src/services/api.ts):withSettingsnow marks each patched collection as authoritative (manage_models/manage_combos/manage_tenants/manage_upstreams=true), because the backend store intentionally refuses to delete rows absent from a payload that does not carry the matchingmanage_*flag (protection against stale 5s-poll payloads) while still answering200 OK— so the dashboard showed "Catalog synchronized" while the deleted entry reappeared on the next GET.- Most visible when deleting the last remaining entry of a collection (the list becomes empty, which is only honored as "delete everything" when authoritative); non-last deletions were already handled by the non-empty authoritative rule.
- Verified end-to-end by
TestSettingsModelDeletion_TursoStore(internal/server/settings_model_delete_test.go): delete-one-of-two, the bug reproduction (empty list without the flag keeps the row), and the fixed path (empty list withmanage_models: truedeletes it).
Release v1.22.0
Changed
- Locked every non-OpenAI/Anthropic
base_urlto its provider default (internal/domain/protocol_endpoint.go,internal/config/builder.go,internal/adapter/qoder, dashboard):- Extended the managed-endpoint pin (
antigravity,cline,codebuddy-cn/-intl,grok-cli) withopencode→https://opencode.ai/zen/v1,opencode-go→https://opencode.ai/zen/go/v1, andqoder→https://api3.qoder.sh, so onlyopenaiandanthropickeep an operator-chosen host.config.Buildandconfig.PinOAuthManagedEndpointsoverwrite whatever a file, database row, or API payload supplies. - Qoder's per-token host split is preserved despite the single pinned value: new
qoder.ResolveBaseURLtreats a configured api3/api2 (or empty) value as "derive from the token" — job tokens (jt-) still route to api2 even when the stored base says api3 — while custom/mock hosts keep working verbatim. Applied in the adapter,ListModels, andFetchModelsso probes agree with forwarding. - The dashboard renders Base URL read-only with the managed value for every locked protocol (was: OAuth protocols only) and keeps refusing fallback hosts.
- Extended the managed-endpoint pin (
Fixed
- Upstream drawer header and Key-issues KPI (
frontend/src/components/upstream/UpstreamDrawer.tsx,frontend/src/components/ui/Drawer.tsx,frontend/src/pages/UpstreamsPage.tsx):- The Key-issues KPI card no longer repeats its value inside the unit ("3 3 cooldown" → "3 cooldown"); a single-issue metric shows a bare label, while the mixed case keeps its cooldown/revoked breakdown.
- The drawer's provider banner no longer duplicates the upstream name already rendered in the drawer header, and both the row and its action group wrap, so the Ping/Edit/Disable buttons no longer overflow a narrow sidebar. Long names truncate instead of pushing the close button out.
- The Ping button no longer spins the non-circular
Activityicon: it swaps to a circularLoader2spinner while a probe is in flight.
Release v1.21.0
Added
- Grok CLI OAuth device-code provider with automatic token refresh (
internal/security/oauth/providers/grokcli,cmd/firefly/main.go,internal/server/oauth_handlers.go):- New
grok-cliOAuth provider implementing the xAI RFC 8628 device authorization flow (auth.x.ai/oauth2/device/code, public client,referrer=grok-build, fulloffline_accessscope) andgrant_type=refresh_tokenrefresh againstauth.x.ai/oauth2/token. - Rotating refresh tokens are honored: each refresh stores xAI's newly issued refresh token and preserves the previous one when the endpoint omits it, so a refresh response can never wipe the credential.
- Registration in
mainplugs the provider into the existing refresh lifecycle with no adapter changes: proactive refresh inTokenSource(5-minute lead), the backgroundRefreshersweep, singleflight-deduplicated concurrent refresh, and fail-closed fallbacks. - Onboarding surfaces the RFC 8628 approval material:
POST /api/oauth/authorizereturnsuser_code+verification_uri(the device-code secret stays server-side in the session), andPOST /api/oauth/pollcompletes the login; provider aliasesgcli/grok-build/grok_cliresolve. - The dashboard offers the OAuth connect banner for
grok-cliupstreams (OAuthConnectDialog) alongside the manual key pool, so OAuth-managed and harvester-pooled credentials coexist per upstream.
- New
- Grok CLI model families 4.6/4.7 with table-driven effort support (
internal/adapter/grok/translate.go):- Replaced the hardcoded
grok-4.5model list/map with acuratedModelscapability table; a new Grok release is onboarded by appending one row. Discovery (SupportedModels) now offersgrok-4.6/grok-4.7plus synthesized-low/-medium/-highvariants, still used only when no credential is available for a live/modelsquery. - Effort suffixes take effect only on curated effort-capable families; unknown/custom ids (e.g.
grok-5-preview) stay verbatim and effort-free, so no unsupportedreasoning.effortfield is ever sent.
- Replaced the hardcoded
Changed
- Locked the grok-cli upstream endpoint (
internal/domain/protocol_endpoint.go,frontend/src/components/upstream/GeneralTab.tsx): addedhttps://cli-chat-proxy.grok.com/v1(the adapter appends/responses) todomain.OAuthManagedBaseURL, soconfig.Build/PinOAuthManagedEndpoints, the upstream probe default, and the dashboard read-only field pin it like the other OAuth-managed providers. Adapters still honorUpstream.BaseURL, so mock hosts in tests are unaffected.
Fixed
- Fail-closed
oauth:credential resolution in the grok adapter (internal/adapter/grok/adapter.go): anoauth:<connection-id>ref that cannot be resolved (missing/revoked connection) previously fell through to forwarding the literal ref string as a bearer token;resolveTokennow rejectsoauth:-prefixed material in both the key-slot and ref fallbacks.
Release v1.20.3
Fixed & Security
- Canonicalize OAuth Protocols & Lock Provider-Managed Endpoints (
internal/domain/protocol_endpoint.go,internal/config/builder.go,internal/server/settings.go,internal/server/upstream_check.go,frontend/src/components/upstream/GeneralTab.tsx,frontend/src/services/schema.ts):- Added
domain.OAuthManagedBaseURLas the single source of truth for OAuth provider endpoints (antigravity,cline,codebuddy-cn,codebuddy-intl). - Strict protocol canonicalization: mapped legacy aliases (
codebuddy,codebuddy_cn,codebuddy_intl,antigravity-go,opencode-go) to canonical protocol identifiers across the config builder, Turso storage migrator, probe/discovery handlers, and frontend schema. - Immutable OAuth endpoints: pinned OAuth-managed base URLs across settings persistence, catalog building, and Turso bootstrap so hand-edited files, Turso rows, or API payloads cannot supply unpinned or custom endpoints for OAuth-authenticated upstreams, eliminating credential exfiltration paths.
- Exported
config.NormalizeProtocoland appliedconfig.PinOAuthManagedEndpointsacross both authenticated and publicGET /api/settingsresponses so legacy stored values on disk or in the database are transparently served as canonical protocols and pinned endpoints. - Eliminated duplicate
codebuddyprotocol options in the Upstream Editor dropdown and Upstreams Page filter lists by binding the<select>toactiveProtocol, normalizingonChange, migrating legacy form state on load, and preventing legacy protocol aliases from rendering duplicate fallback<option>elements. - Upstream Editor enhancements: added per-model connectivity checks with provider-aware endpoint resolution and improved model discovery error surfacing in
ModelsTab.
- Added
Release v1.20.2
Fixed
- WARP Engine Reported as
DISABLEDon a Cold Start (cmd/firefly/main.go,internal/transport/warp/manager.go,frontend/src/pages/SettingsPage.tsx): The tunnel was only dialled lazily — by the first request through a WARP-egress upstream, or by the first auto-rotation tick a full-warp-rotate-interval(5m by default) after boot. A fresh deployment (e.g. a new container with an empty/etc/fireflyvolume) therefore answeredenabled: falsetoGET /api/warp/statusfor minutes, the Settings → WARP Engine card showed a perfectly healthy engine asDISABLED, and the "Rotate now" button was disabled whileenabled == false, so the operator could not even force the first session from the UI.warp.Manager.WarmUp(ctx)establishes (or restores fromwarp_identity.json) the tunnel during startup under the existing singleflight, so the session is published before the first request instead of on it.cmd/firefly/main.gocalls it from a background goroutine after both rotation observers are registered: it never blocks startup, and it is skipped when automatic rotation is off (-warp-rotate-interval=0, no autonomous WARP work).- A warm-up failure is never fatal: the tunnel stays lazy, the next request or rotation retries, and the reason is recorded so
Status().Errorexplains the badge. Shutdown racing the warm-up is a no-op rather than an error. - Frontend: the WARP card now surfaces
errorfrom the status payload (badgeUNAVAILABLEwith the reason in the title instead of a neutralDISABLED, plus a monospace error panel mirroring the Tunnel card), and the action button readsStart tunnel/Rotate nowand stays clickable while no session exists — the endpoint establishes one. -warp-rotate-intervalis now applied to the manager even when it is0. Previously the flag was only forwarded inside the> 0branch, so the constructor default (5m) survived andGET /api/warp/statuskept advertisingauto_rotate_interval_seconds: 300with a futurenext_rotation_atwhile no scheduler was running — the dashboard showed a live 5-minute schedule for a rotation that would never happen.- Tests (
internal/transport/warp/manager_test.go):TestManager_WarmUpEstablishesTunnelOnColdStart(publishes exactly one session, idempotent on a second call),TestManager_WarmUpReportsFailureWithoutPublishing,TestManager_WarmUpIsNoopAfterClose, andTestManager_StatusReportsDisabledScheduleWhenIntervalIsZero.