Skip to content

Releases: dickymuliafiqri/firefly

Release v1.25.1

Choose a tag to compare

@github-actions github-actions released this 27 Sep 11:53

Release v1.25.1

See CHANGELOG.md for full change history.

Release v1.25.0

Choose a tag to compare

@github-actions github-actions released this 27 Sep 11:16

Added

  • Persistent Cross-View Model Cache (frontend/src/services/modelCache.ts, frontend/src/components/upstream/ModelsTab.tsx, frontend/src/pages/ModelsPage.tsx):
    • New Zustand persist store (firefly-model-cache in localStorage) 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.
    • ModelsTab writes 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 / getAllCachedModels plus pickOptimalProbeModel, 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.
    • ModelForm on 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/models against the selected upstream, first credential as fallback key).
  • Bounded-Concurrency Model Health Checks (frontend/src/components/upstream/ModelsTab.tsx):
    • Check all health now 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 model field is gone from General & Network; probeModel is now dedicated editor state loaded from target.probe_model and 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 PROBE in the discovery table.
    • KeysTab keeps receiving the same value for per-key inference probes — request behavior is unchanged, only the input surface moved.
  • Resilience Error Threshold Defaults to Disabled (frontend/src/components/upstream/ResilienceTab.tsx, frontend/src/pages/UpstreamEditorPage.tsx):
    • keyErrorThreshold now defaults to 0 (new upstreams and stored upstreams without the field), matching the backend's threshold > 0 guard in upstream.HandleKeyOutcome, so no consecutive-error action fires unless the operator opts in. The input is min="0" with hint text and sanitizes negative/NaN input to 0; granular rule thresholds are clamped to min 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

Choose a tag to compare

@github-actions github-actions released this 27 Sep 09:58

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.SaveSettings indexes every stored row under two identities in existingTenants (api_key and key_hash), but only the payload's own identity was registered in activeTenants — and only when the payload supplied key_hash. The hash the store derives from a plaintext sk-gw-… key (line-matched against existingTenants a few lines later) was never registered. The dashboard's edit form rebuilds the tenant entry from its own fields, so the payload carries api_key but no key_hash: the row was updated and then immediately removed by the "delete tenants absent from an authoritative payload" sweep, which matched the row's key_hash entry that had never been marked active. Creating a tenant was unaffected because handleCreateTenant fills KeyHash in before persisting.
    • The derived hash is now registered in activeTenants alongside 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_TenantRemovalStillHonored asserts that a payload genuinely omitting a tenant still removes it, and TestSaveSettings_TenantEditKeepsSiblings asserts that editing one tenant leaves the others (and their credentials) untouched. TestSaveSettings_TenantEditRoundTrip reproduces the dashboard round trip (load → edit → re-save with manage_tenants: true) and reported tenants now: [] before the fix.

Release v1.24.0

Choose a tag to compare

@github-actions github-actions released this 27 Sep 09:30

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/telemetry every 2 seconds — was routinely served by a process that had never seen that token, Manager.ValidateToken returned false, /api/auth/verify answered 401, and frontend/src/services/api.ts treated that as a dead session: it cleared the token from localStorage and 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 generation ep) 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), then session_secret in configs/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 same auth.json. Where no shared state exists, rotate $FIREFLY_SESSION_SECRET to 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. RevokeSession only 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, plus TestAuthManager_RevokeSession/TestAuthManager_ConcurrentAccess updated 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.
  • null Key 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/telemetry serialized an upstream with zero credentials (created with just a name and base_url, so its KeyRing holds no slots) as "slots": null — a Go nil slice marshals to JSON null, not []. The dashboard's useMemo then called .filter on null (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 test TestTelemetry_UpstreamWithoutSlots (nil KeyRing and empty KeyRing both assert no "slots":null reaches the wire).
    • Defense in depth: UpstreamTelemetryDTO.slots is typed nullable so TypeScript forces consumers to normalize (?? []) — UpstreamsPage and UpstreamDrawer now 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, inside AppShell) keeps sidebar/navbar/toasts alive and offers "Try again" / "Reload page"; a root boundary in main.tsx is the last resort if the shell itself throws.

Release v1.23.1

Choose a tag to compare

@github-actions github-actions released this 27 Sep 01:08

Security

  • Upgraded vite 5.4.21 → 6.4.3 in frontend/ and archive/frontend/, closing every open Dependabot alert (6 total: 2 high, 4 moderate):
    • GHSA-fx2h-pf6j-xcff (high): server.fs.deny bypass on Windows alternate paths.
    • GHSA-4w7w-66w2-5vf9 (moderate): path traversal in optimized deps .map handling.
    • 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 (esbuild moved from 0.21.5 to the ^0.25.0 line bundled by vite 6.4.3).
    • @vitejs/plugin-react@4.7.0 already declares vite 6 in its peer range, so only vite itself was bumped. bun audit is now clean for both manifests, bun run build (typecheck + vite build) passes, and frontend/dist/index.html is intact for //go:embed all:dist.

Release v1.23.0

Choose a tag to compare

@github-actions github-actions released this 27 Sep 00:54

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/completions request 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.Forward so every protocol (openai, anthropic, ...) is covered.
    • Settings API rejects system_prompt payloads above domain.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.json and the Turso token_saver settings 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), extended TestSettingsTokenSaverPersistence, and TestSettingsRejectsOversizedSystemPrompt.

Fixed

  • Model/Combo/Tenant/Upstream deletion silently ignored by the Turso store (frontend/src/services/api.ts):
    • withSettings now 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 matching manage_* flag (protection against stale 5s-poll payloads) while still answering 200 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 with manage_models: true deletes it).

Release v1.22.0

Choose a tag to compare

@github-actions github-actions released this 26 Sep 13:04

Changed

  • Locked every non-OpenAI/Anthropic base_url to 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) with opencode → https://opencode.ai/zen/v1, opencode-go → https://opencode.ai/zen/go/v1, and qoder → https://api3.qoder.sh, so only openai and anthropic keep an operator-chosen host. config.Build and config.PinOAuthManagedEndpoints overwrite whatever a file, database row, or API payload supplies.
    • Qoder's per-token host split is preserved despite the single pinned value: new qoder.ResolveBaseURL treats 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, and FetchModels so 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.

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 Activity icon: it swaps to a circular Loader2 spinner while a probe is in flight.

Release v1.21.0

Choose a tag to compare

@github-actions github-actions released this 26 Sep 11:47

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-cli OAuth provider implementing the xAI RFC 8628 device authorization flow (auth.x.ai/oauth2/device/code, public client, referrer=grok-build, full offline_access scope) and grant_type=refresh_token refresh against auth.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 main plugs the provider into the existing refresh lifecycle with no adapter changes: proactive refresh in TokenSource (5-minute lead), the background Refresher sweep, singleflight-deduplicated concurrent refresh, and fail-closed fallbacks.
    • Onboarding surfaces the RFC 8628 approval material: POST /api/oauth/authorize returns user_code + verification_uri (the device-code secret stays server-side in the session), and POST /api/oauth/poll completes the login; provider aliases gcli/grok-build/grok_cli resolve.
    • The dashboard offers the OAuth connect banner for grok-cli upstreams (OAuthConnectDialog) alongside the manual key pool, so OAuth-managed and harvester-pooled credentials coexist per upstream.
  • Grok CLI model families 4.6/4.7 with table-driven effort support (internal/adapter/grok/translate.go):
    • Replaced the hardcoded grok-4.5 model list/map with a curatedModels capability table; a new Grok release is onboarded by appending one row. Discovery (SupportedModels) now offers grok-4.6/grok-4.7 plus synthesized -low/-medium/-high variants, still used only when no credential is available for a live /models query.
    • 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 unsupported reasoning.effort field is ever sent.

Changed

  • Locked the grok-cli upstream endpoint (internal/domain/protocol_endpoint.go, frontend/src/components/upstream/GeneralTab.tsx): added https://cli-chat-proxy.grok.com/v1 (the adapter appends /responses) to domain.OAuthManagedBaseURL, so config.Build/PinOAuthManagedEndpoints, the upstream probe default, and the dashboard read-only field pin it like the other OAuth-managed providers. Adapters still honor Upstream.BaseURL, so mock hosts in tests are unaffected.

Fixed

  • Fail-closed oauth: credential resolution in the grok adapter (internal/adapter/grok/adapter.go): an oauth:<connection-id> ref that cannot be resolved (missing/revoked connection) previously fell through to forwarding the literal ref string as a bearer token; resolveToken now rejects oauth:-prefixed material in both the key-slot and ref fallbacks.

Release v1.20.3

Choose a tag to compare

@github-actions github-actions released this 26 Sep 09:10

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.OAuthManagedBaseURL as 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.NormalizeProtocol and applied config.PinOAuthManagedEndpoints across both authenticated and public GET /api/settings responses so legacy stored values on disk or in the database are transparently served as canonical protocols and pinned endpoints.
    • Eliminated duplicate codebuddy protocol options in the Upstream Editor dropdown and Upstreams Page filter lists by binding the <select> to activeProtocol, normalizing onChange, 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.

Release v1.20.2

Choose a tag to compare

@github-actions github-actions released this 26 Sep 03:25

Fixed

  • WARP Engine Reported as DISABLED on 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/firefly volume) therefore answered enabled: false to GET /api/warp/status for minutes, the Settings → WARP Engine card showed a perfectly healthy engine as DISABLED, and the "Rotate now" button was disabled while enabled == false, so the operator could not even force the first session from the UI.
    • warp.Manager.WarmUp(ctx) establishes (or restores from warp_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.go calls 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().Error explains the badge. Shutdown racing the warm-up is a no-op rather than an error.
    • Frontend: the WARP card now surfaces error from the status payload (badge UNAVAILABLE with the reason in the title instead of a neutral DISABLED, plus a monospace error panel mirroring the Tunnel card), and the action button reads Start tunnel / Rotate now and stays clickable while no session exists — the endpoint establishes one.
    • -warp-rotate-interval is now applied to the manager even when it is 0. Previously the flag was only forwarded inside the > 0 branch, so the constructor default (5m) survived and GET /api/warp/status kept advertising auto_rotate_interval_seconds: 300 with a future next_rotation_at while 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, and TestManager_StatusReportsDisabledScheduleWhenIntervalIsZero.