feat(desktop): show the AI assistant in SaaS mode via the cloud kill switch (#6666)

## What & why

Chained on top of #6649 (the `cloud/` refactor). The AI assistant was
effectively dead on desktop:

1. **Hidden** — `ChatFAB` gates on `aiEngineEnabled`, which desktop
reads from the **local** bundled backend's `/api/v1/config/app-config`.
The local backend has no AI engine, so the flag is always `false` and
the FAB never renders.
2. **Mis-routed** — even if shown, AI calls used `getApiBaseUrl()`,
which is empty/local on desktop, so the orchestrate stream and AI
result-file download missed the engine (which only runs in the cloud).

This PR wires AI properly **without hardcoding it on**, so the cloud
keeps the kill switch: flip `aiEngineEnabled` server-side and the
desktop FAB disappears on the next load — no desktop release required.
(Deliberately *not* assume-on, so a future "turn AI off" doesn't strand
shipped versions.)

## Changes

**General SaaS app-config service** (reusable for any cloud flag, not
just AI):
- `desktop/services/saasAppConfigService.ts` — SaaS-mode-only fetch +
5-min cache of the **public** `/api/v1/config/app-config` from the
**SaaS** backend over native HTTP (`@tauri-apps/plugin-http`, no CORS).
Returns `null` outside SaaS mode.
- `desktop/hooks/useSaasAppConfig.ts` — hook over it; reloads on
connection-mode change.

**AI gating + routing seams:**
- `useAiEngineEnabled()` — core reads `useAppConfig()` (web), desktop
reads `useSaasAppConfig()`. `ChatFAB` consumes it.
- `getAiBaseUrl()` — core uses the normal API base (web), desktop points
AI calls at the SaaS backend. `ChatContext` uses it for the orchestrate
stream + result-file download.
- `operationRouter` — route `/api/v1/ai/*` to the SaaS backend
(cloud-only prefix).

**Docs:** AGENTS.md gains a short "cloud feature flags on desktop" note
so the pattern is maintained.

## Verification
- `tsc --noEmit` green for saas / desktop / cloud flavors
- `eslint --max-warnings=0` clean (cloud-layer guardrail respected — the
platform-coupled bits live in `desktop/`)
- New `saasAppConfigService.test.ts` (3 tests) + existing
`operationRouter` / `tauriHttpClient` / `httpErrorHandler` suites green
- 0 stray compiled artifacts

## Not headlessly verifiable — needs a live Tauri smoke
The orchestrate **SSE stream** uses the webview's global `fetch` (native
HTTP can't stream the body the same way), so it's subject to browser
CORS to the SaaS backend. The `SupabaseSecurityConfig` tauri-origin
allowance (from #6649) covers it, but please confirm on a real build:
open the FAB in SaaS mode, run an agent task, watch the stream + a
result-file download succeed.
This commit is contained in:
ConnorYoh
2026-06-17 14:10:35 +00:00
committed by GitHub
parent df9dbc5179
commit 4f26fdeb5c
13 changed files with 346 additions and 14 deletions
+2
View File
@@ -193,6 +193,8 @@ What goes where:
Rule of thumb — **move, don't copy**: share via `cloud/`, override by shadowing the same `@app/*` path in a leaf (`saas/` or `desktop/`).
**Cloud feature flags on desktop.** The local `AppConfigContext` reads `/api/v1/config/app-config` from the LOCAL bundled backend, so cloud-only flags (`aiEngineEnabled`, `premiumEnabled`, …) are never seen on desktop. To read the cloud's view, use `useSaasAppConfig()` (`desktop/hooks/useSaasAppConfig.ts`, backed by the general `saasAppConfigService` — SaaS-mode-only, public endpoint, native HTTP, 5-min cache). It returns `null` outside SaaS mode, so cloud features stay off in local/self-hosted and the server keeps the on/off switch (no desktop release needed to flip a flag). Gate a feature behind a per-platform seam — e.g. `useAiEngineEnabled()` (core reads `useAppConfig()`, desktop reads `useSaasAppConfig()`) — rather than hardcoding the flag on.
#### Component Override Pattern (Stub/Shadow)
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
@@ -0,0 +1,13 @@
import { useAppConfig } from "@app/contexts/AppConfigContext";
/**
* Whether the AI engine is enabled, per the backend's app-config.
*
* Web builds read it straight from the app-config (which already comes from the
* backend the app talks to). Desktop shadows this (desktop/hooks/useAiEngineEnabled)
* to read it from the SaaS backend in SaaS mode instead of the local bundled one.
*/
export function useAiEngineEnabled(): boolean {
const { config } = useAppConfig();
return Boolean(config?.aiEngineEnabled);
}
@@ -0,0 +1,12 @@
import { getApiBaseUrl } from "@app/services/apiClientConfig";
/**
* Base URL for AI-engine calls (orchestrate stream, AI result-file download).
*
* Web builds talk to whichever backend the app is served from, so the normal API
* base is correct. Desktop shadows this (desktop/services/aiBaseUrl) to point at
* the SaaS backend, since the AI engine only runs in the cloud.
*/
export function getAiBaseUrl(): string {
return getApiBaseUrl();
}
@@ -0,0 +1,13 @@
import { useSaasAppConfig } from "@app/hooks/useSaasAppConfig";
/**
* Desktop: the AI engine runs on the SaaS backend, so its enabled flag must come
* from the SaaS app-config (not the local bundled backend, which never has the
* engine). useSaasAppConfig() returns null outside SaaS mode, so AI is implicitly
* hidden in local/self-hosted — and the cloud retains the on/off switch (flip
* aiEngineEnabled server-side and the desktop FAB disappears on next load, no
* release required).
*/
export function useAiEngineEnabled(): boolean {
return Boolean(useSaasAppConfig()?.aiEngineEnabled);
}
@@ -0,0 +1,36 @@
import { useEffect, useState } from "react";
import { saasAppConfigService } from "@app/services/saasAppConfigService";
import { connectionModeService } from "@app/services/connectionModeService";
import type { AppConfig } from "@app/types/appConfig";
/**
* The SaaS backend's app-config while in desktop SaaS mode, or null otherwise.
*
* General-purpose: read any cloud feature flag from it (e.g.
* `useSaasAppConfig()?.aiEngineEnabled`, `?.premiumEnabled`). Reloads when the
* connection mode changes, so switching into/out of SaaS updates the flags
* (and a server-side flag flip is picked up on the next load).
*/
export function useSaasAppConfig(): AppConfig | null {
const [config, setConfig] = useState<AppConfig | null>(null);
useEffect(() => {
let alive = true;
const load = () => {
void saasAppConfigService.getConfig().then((c) => {
if (alive) setConfig(c);
});
};
load();
const unsubscribe = connectionModeService.subscribeToModeChanges(() => {
saasAppConfigService.clearCache();
load();
});
return () => {
alive = false;
unsubscribe?.();
};
}, []);
return config;
}
@@ -0,0 +1,14 @@
import { STIRLING_SAAS_BACKEND_API_URL } from "@app/constants/connection";
/**
* Desktop: AI-engine calls must hit the SaaS backend (the local bundled backend
* has no AI engine). The AI surface is only shown in SaaS mode (gated by
* useAiEngineEnabled), so returning the SaaS base unconditionally is correct —
* when an AI call is made, the app is in SaaS mode.
*
* Used for the orchestrate stream (a raw fetch) and the AI result-file download,
* which would otherwise resolve to the empty/local base and miss the engine.
*/
export function getAiBaseUrl(): string {
return (STIRLING_SAAS_BACKEND_API_URL ?? "").replace(/\/$/, "");
}
@@ -6,6 +6,11 @@ import { expectConsole } from "@app/tests/failOnConsole";
// Exercise the error-interceptor logic against a hand-rolled axios-like
// client; transitive imports are mocked out so this is a pure unit test.
const { getAccessTokenMock } = vi.hoisted(() => ({
getAccessTokenMock: vi.fn(),
}));
vi.mock("@app/auth/session", () => ({ getAccessToken: getAccessTokenMock }));
vi.mock("@app/components/toast", () => ({ alert: vi.fn() }));
vi.mock("@core/services/apiClientSetup", () => ({
setupApiInterceptors: vi.fn(),
@@ -50,7 +55,11 @@ vi.mock("@app/i18n", () => ({
default: { t: (_key: string, fallback: string) => fallback || _key },
}));
import { setupApiInterceptors } from "@app/services/apiClientSetup";
import {
setupApiInterceptors,
getAuthHeaders,
} from "@app/services/apiClientSetup";
import { operationRouter } from "@app/services/operationRouter";
type Interceptor = (value: unknown) => unknown;
@@ -177,3 +186,66 @@ describe("desktop apiClientSetup - 401 silent-path", () => {
expect(events).toHaveLength(0);
});
});
describe("desktop request interceptor - auth for SaaS-backend requests", () => {
type ReqConfig = {
url: string;
method: string;
headers: Record<string, string>;
};
async function runRequestInterceptor(config: ReqConfig): Promise<ReqConfig> {
const { client, handlers } = makeMockClient();
setupApiInterceptors(client as unknown as AxiosInstance);
const handler = handlers.request[0];
expect(handler).toBeTypeOf("function");
return (await handler(config)) as ReqConfig;
}
beforeEach(() => {
getAccessTokenMock.mockReset();
// SaaS mode (not self-hosted), local-first routing for tool paths.
vi.mocked(operationRouter.isSelfHostedMode).mockResolvedValue(false);
vi.mocked(operationRouter.isSaaSMode).mockResolvedValue(true);
vi.mocked(operationRouter.getBaseUrl).mockResolvedValue(
"http://localhost:8080",
);
vi.mocked(operationRouter.shouldSkipBackendReadyCheck).mockResolvedValue(
true,
);
});
test("attaches the Bearer token to an absolute SaaS-backend URL (AI file download)", async () => {
getAccessTokenMock.mockResolvedValue("jwt-123");
const result = await runRequestInterceptor({
url: "https://api.saas.test/api/v1/general/files/abc",
method: "get",
headers: {},
});
expect(result.headers.Authorization).toBe("Bearer jwt-123");
});
test("does NOT attach a token to a relative, local-routed tool request", async () => {
getAccessTokenMock.mockResolvedValue("jwt-123");
const result = await runRequestInterceptor({
url: "/api/v1/general/merge-pdfs",
method: "get",
headers: {},
});
expect(result.headers.Authorization).toBeUndefined();
});
});
describe("desktop getAuthHeaders (raw fetch / AI SSE stream)", () => {
beforeEach(() => getAccessTokenMock.mockReset());
test("attaches the Tauri-store JWT as a Bearer header", async () => {
getAccessTokenMock.mockResolvedValue("jwt-123");
expect(await getAuthHeaders()).toEqual({ Authorization: "Bearer jwt-123" });
});
test("returns no header when there is no token", async () => {
getAccessTokenMock.mockResolvedValue(null);
expect(await getAuthHeaders()).toEqual({});
});
});
@@ -1,11 +1,6 @@
import type { AxiosInstance, InternalAxiosRequestConfig } from "axios";
import { alert } from "@app/components/toast";
import {
setupApiInterceptors as coreSetup,
getAuthHeaders,
} from "@core/services/apiClientSetup";
export { getAuthHeaders };
import { setupApiInterceptors as coreSetup } from "@core/services/apiClientSetup";
import { tauriBackendService } from "@app/services/tauriBackendService";
import { createBackendNotReadyError } from "@app/constants/backendErrors";
import { operationRouter } from "@app/services/operationRouter";
@@ -19,6 +14,21 @@ import {
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
import i18n from "@app/i18n";
/**
* Auth headers for raw fetch() calls (the AI SSE stream) — desktop variant.
*
* Core's getAuthHeaders returns {} and proprietary's reads the JWT from
* localStorage, but desktop keeps its JWT in the Tauri secure store. So this
* pulls the token via getAccessToken() (mirroring the axios request interceptor,
* including waiting out an in-flight refresh) — without it the orchestrate
* stream hits the SaaS backend with no Authorization header and 401s.
*/
export async function getAuthHeaders(): Promise<Record<string, string>> {
await authService.awaitRefreshIfInProgress();
const token = await getAccessToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
const BACKEND_TOAST_COOLDOWN_MS = 4000;
let lastBackendToast = 0;
@@ -71,7 +81,17 @@ export function setupApiInterceptors(client: AxiosInstance): void {
// - SaaS backend: Needs auth token
// - Self-hosted backend: Needs auth token
const isRemote = await operationRouter.isSelfHostedMode();
const isSaaSBackendRequest = baseUrl === STIRLING_SAAS_BACKEND_API_URL;
// A request targets the SaaS backend either because operationRouter
// routed a relative path there, OR because the caller passed an absolute
// SaaS URL. The latter matters for the AI result-file download: it hits
// ${getAiBaseUrl()}/api/v1/general/files/..., and /api/v1/general/*
// normally routes local-first — so without this it would reach the cloud
// engine with no token and 401. Check the FINAL url (post-prefixing).
const saasBase = STIRLING_SAAS_BACKEND_API_URL ?? "";
const finalUrl = extendedConfig.url ?? "";
const isSaaSBackendRequest =
baseUrl === STIRLING_SAAS_BACKEND_API_URL ||
(saasBase !== "" && finalUrl.startsWith(saasBase));
const needsAuth = isRemote || isSaaSBackendRequest;
// Tag request so error handler can identify SaaS backend errors without URL matching
@@ -60,6 +60,7 @@ export class OperationRouter {
/^\/api\/v1\/auth\//, // Supabase auth (SaaS mode)
/^\/api\/v1\/payg\//, // PAYG wallet / spend-cap / billing
/^\/api\/v1\/policies(?:\/|$)/, // Policy runs — must bill via the cloud
/^\/api\/v1\/ai\//, // AI engine (orchestrate, etc.) — runs in the cloud
// Add more cloud-only feature prefixes here as they land.
];
@@ -0,0 +1,64 @@
import { describe, expect, test, vi, beforeEach } from "vitest";
import { expectConsole } from "@app/tests/failOnConsole";
const { fetchMock, getModeMock } = vi.hoisted(() => ({
fetchMock: vi.fn(),
getModeMock: vi.fn(),
}));
vi.mock("@tauri-apps/plugin-http", () => ({ fetch: fetchMock }));
vi.mock("@app/constants/connection", () => ({
STIRLING_SAAS_BACKEND_API_URL: "https://api.saas.test",
}));
vi.mock("@app/services/connectionModeService", () => ({
connectionModeService: { getCurrentMode: getModeMock },
}));
import { saasAppConfigService } from "@app/services/saasAppConfigService";
function okConfig(body: Record<string, unknown>) {
return { ok: true, status: 200, json: async () => body };
}
describe("saasAppConfigService", () => {
beforeEach(() => {
fetchMock.mockReset();
getModeMock.mockReset();
saasAppConfigService.clearCache();
});
test("returns null outside SaaS mode without fetching", async () => {
getModeMock.mockResolvedValue("local");
expect(await saasAppConfigService.getConfig()).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
test("fetches the SaaS app-config in SaaS mode and caches it", async () => {
getModeMock.mockResolvedValue("saas");
fetchMock.mockResolvedValue(
okConfig({ aiEngineEnabled: true, premiumEnabled: false }),
);
const first = await saasAppConfigService.getConfig();
expect(first?.aiEngineEnabled).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe(
"https://api.saas.test/api/v1/config/app-config",
);
// Second read is served from cache (no extra fetch).
const second = await saasAppConfigService.getConfig();
expect(second?.aiEngineEnabled).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test("returns null on a non-ok response", async () => {
getModeMock.mockResolvedValue("saas");
fetchMock.mockResolvedValue({
ok: false,
status: 500,
json: async () => ({}),
});
expectConsole.warn(/SaaS app-config fetch failed: 500/);
expect(await saasAppConfigService.getConfig()).toBeNull();
});
});
@@ -0,0 +1,82 @@
import { fetch } from "@tauri-apps/plugin-http";
import { STIRLING_SAAS_BACKEND_API_URL } from "@app/constants/connection";
import { connectionModeService } from "@app/services/connectionModeService";
import type { AppConfig } from "@app/types/appConfig";
/**
* General fetch+cache of the SaaS backend's app-config for the desktop app.
*
* The local AppConfigContext reads /api/v1/config/app-config from the LOCAL
* bundled backend (that's what apiClient routes to on desktop), so cloud-only
* feature flags (aiEngineEnabled, premiumEnabled, paygEnabled, ...) are never
* seen. This service fetches the SAME endpoint from the SaaS backend so desktop
* SaaS mode can read the cloud's view of those flags. It is intentionally
* generic — not AI-specific — so other desktop SaaS features can consume it.
*
* - Only returns config in SaaS connection mode; null in local/self-hosted (so
* callers naturally treat cloud features as off outside SaaS, and a server-side
* flag flip — e.g. turning AI off — propagates without a desktop release).
* - The endpoint is public on the SaaS backend (/api/v1/config/** is permitAll),
* so no auth is required.
* - Native HTTP (@tauri-apps/plugin-http) — no CORS, mirrors endpointAvailabilityService.
*/
const CACHE_MS = 5 * 60 * 1000;
class SaasAppConfigService {
private cache: AppConfig | null = null;
private cacheExpiry = 0;
private inFlight: Promise<AppConfig | null> | null = null;
/** The SaaS backend's app-config, or null outside SaaS mode / on failure. */
async getConfig(force = false): Promise<AppConfig | null> {
const mode = await connectionModeService.getCurrentMode();
if (mode !== "saas" || !STIRLING_SAAS_BACKEND_API_URL) {
this.clearCache();
return null;
}
if (!force && this.cache && Date.now() < this.cacheExpiry) {
return this.cache;
}
if (this.inFlight) return this.inFlight;
this.inFlight = this.fetchConfig();
try {
return await this.inFlight;
} finally {
this.inFlight = null;
}
}
private async fetchConfig(): Promise<AppConfig | null> {
try {
const base = STIRLING_SAAS_BACKEND_API_URL.replace(/\/$/, "");
const response = await fetch(`${base}/api/v1/config/app-config`, {
method: "GET",
headers: { "Cache-Control": "no-store" },
});
if (!response.ok) {
console.warn(
`[saasAppConfigService] SaaS app-config fetch failed: ${response.status}`,
);
return null;
}
const data = (await response.json()) as AppConfig;
this.cache = data;
this.cacheExpiry = Date.now() + CACHE_MS;
return data;
} catch (error) {
console.error(
"[saasAppConfigService] SaaS app-config fetch error:",
error,
);
return null;
}
}
/** Drop the cache (e.g. on connection-mode change) so the next read re-fetches. */
clearCache(): void {
this.cache = null;
this.cacheExpiry = 0;
}
}
export const saasAppConfigService = new SaasAppConfigService();
@@ -10,7 +10,7 @@ import { useTranslation } from "react-i18next";
import { generateId } from "@app/utils/generateId";
import { useAllFiles, useFileActions } from "@app/contexts/FileContext";
import apiClient from "@app/services/apiClient";
import { getApiBaseUrl } from "@app/services/apiClientConfig";
import { getAiBaseUrl } from "@app/services/aiBaseUrl";
import { getAuthHeaders } from "@app/services/apiClientSetup";
import { dispatchPaygLimitReached } from "@app/services/usageLimitBridge";
import { createChildStub } from "@app/contexts/file/fileActions";
@@ -407,8 +407,10 @@ export function ChatProvider({ children }: { children: ReactNode }) {
// Download a File from the Stirling files endpoint.
const downloadFile = useCallback(
async (descriptor: AiWorkflowResultFile): Promise<File> => {
// AI result files live on the backend that ran the workflow (the SaaS
// engine on desktop), so fetch from the AI base, not the local backend.
const response = await apiClient.get<Blob>(
`/api/v1/general/files/${descriptor.fileId}`,
`${getAiBaseUrl()}/api/v1/general/files/${descriptor.fileId}`,
{ responseType: "blob" },
);
return new File([response.data], descriptor.fileName, {
@@ -520,7 +522,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
formData.append(`conversationHistory[${i}].content`, message.content);
});
const response = await fetch(
`${getApiBaseUrl()}/api/v1/ai/orchestrate/stream`,
`${getAiBaseUrl()}/api/v1/ai/orchestrate/stream`,
{
method: "POST",
body: formData,
@@ -6,7 +6,7 @@ import { ChatFABButton } from "@shared/components/ChatFABButton";
import { ChatFABWindow } from "@shared/components/ChatFABWindow";
import { ChatPanel } from "@app/components/chat/ChatPanel";
import { useChat } from "@app/components/chat/ChatContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useAiEngineEnabled } from "@app/hooks/useAiEngineEnabled";
import { Z_INDEX_CHAT_FAB_OVERLAY } from "@app/styles/zIndex";
import "@app/components/chat/ChatFAB.css";
@@ -53,8 +53,9 @@ export function ChatFAB() {
const [isOpen, setIsOpen] = useState(false);
const [hasUnviewedResult, setHasUnviewedResult] = useState(false);
const { isLoading } = useChat();
const { config } = useAppConfig();
const enabled = Boolean(config?.aiEngineEnabled);
// Desktop sources this from the SaaS backend (cloud kill switch); web reads it
// from the local app-config. Either way the AI engine drives FAB visibility.
const enabled = useAiEngineEnabled();
// Detect loading → done transition. If the FAB is closed when the agent
// finishes, show the tick badge until the user opens the panel.