feat: self healing skyvern api key (#3614)
Co-authored-by: Suchintan <suchintan@users.noreply.github.com> Co-authored-by: Shuchang Zheng <wintonzheng0325@gmail.com>
This commit is contained in:
@@ -1,4 +1,10 @@
|
||||
import { apiBaseUrl, artifactApiBaseUrl, envCredential } from "@/util/env";
|
||||
import {
|
||||
apiBaseUrl,
|
||||
artifactApiBaseUrl,
|
||||
getRuntimeApiKey,
|
||||
persistRuntimeApiKey,
|
||||
clearRuntimeApiKey,
|
||||
} from "@/util/env";
|
||||
import axios from "axios";
|
||||
|
||||
type ApiVersion = "sans-api-v1" | "v1" | "v2";
|
||||
@@ -9,12 +15,15 @@ const url = new URL(apiBaseUrl);
|
||||
const pathname = url.pathname.replace("/api", "");
|
||||
const apiSansApiV1BaseUrl = `${url.origin}${pathname}`;
|
||||
|
||||
const initialApiKey = getRuntimeApiKey();
|
||||
const apiKeyHeader = initialApiKey ? { "X-API-Key": initialApiKey } : {};
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: apiV1BaseUrl,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-user-agent": "skyvern-ui",
|
||||
"x-api-key": envCredential,
|
||||
...apiKeyHeader,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -23,7 +32,7 @@ const v2Client = axios.create({
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-user-agent": "skyvern-ui",
|
||||
"x-api-key": envCredential,
|
||||
...apiKeyHeader,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -32,7 +41,7 @@ const clientSansApiV1 = axios.create({
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-user-agent": "skyvern-ui",
|
||||
"x-api-key": envCredential,
|
||||
...apiKeyHeader,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -55,12 +64,14 @@ export function removeAuthorizationHeader() {
|
||||
}
|
||||
|
||||
export function setApiKeyHeader(apiKey: string) {
|
||||
persistRuntimeApiKey(apiKey);
|
||||
client.defaults.headers.common["X-API-Key"] = apiKey;
|
||||
v2Client.defaults.headers.common["X-API-Key"] = apiKey;
|
||||
clientSansApiV1.defaults.headers.common["X-API-Key"] = apiKey;
|
||||
}
|
||||
|
||||
export function removeApiKeyHeader() {
|
||||
clearRuntimeApiKey();
|
||||
if (client.defaults.headers.common["X-API-Key"]) {
|
||||
delete client.defaults.headers.common["X-API-Key"];
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@ import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { statusIsNotFinalized } from "@/routes/tasks/types";
|
||||
import { useClientIdStore } from "@/store/useClientIdStore";
|
||||
import {
|
||||
envCredential,
|
||||
environment,
|
||||
wssBaseUrl,
|
||||
newWssBaseUrl,
|
||||
getRuntimeApiKey,
|
||||
} from "@/util/env";
|
||||
import { cn } from "@/util/utils";
|
||||
|
||||
@@ -140,22 +140,18 @@ function BrowserStream({
|
||||
|
||||
const getWebSocketParams = useCallback(async () => {
|
||||
const clientIdQueryParam = `client_id=${clientId}`;
|
||||
let credentialQueryParam = "";
|
||||
const runtimeApiKey = getRuntimeApiKey();
|
||||
|
||||
if (environment === "local") {
|
||||
credentialQueryParam = `apikey=${envCredential}`;
|
||||
} else {
|
||||
if (credentialGetter) {
|
||||
const token = await credentialGetter();
|
||||
credentialQueryParam = `token=Bearer ${token}`;
|
||||
} else {
|
||||
credentialQueryParam = `apikey=${envCredential}`;
|
||||
}
|
||||
let credentialQueryParam = runtimeApiKey ? `apikey=${runtimeApiKey}` : "";
|
||||
|
||||
if (environment !== "local" && credentialGetter) {
|
||||
const token = await credentialGetter();
|
||||
credentialQueryParam = token ? `token=Bearer ${token}` : "";
|
||||
}
|
||||
|
||||
const params = [credentialQueryParam, clientIdQueryParam].join("&");
|
||||
|
||||
return `${params}`;
|
||||
return credentialQueryParam
|
||||
? `${credentialQueryParam}&${clientIdQueryParam}`
|
||||
: clientIdQueryParam;
|
||||
}, [clientId, credentialGetter]);
|
||||
|
||||
// browser is ready
|
||||
|
||||
202
skyvern-frontend/src/components/SelfHealApiKeyBanner.tsx
Normal file
202
skyvern-frontend/src/components/SelfHealApiKeyBanner.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { getClient, setApiKeyHeader } from "@/api/AxiosClient";
|
||||
import {
|
||||
AuthStatusValue,
|
||||
useAuthDiagnostics,
|
||||
} from "@/hooks/useAuthDiagnostics";
|
||||
|
||||
type BannerStatus = Exclude<AuthStatusValue, "ok"> | "error";
|
||||
|
||||
function getCopy(status: BannerStatus): { title: string; description: string } {
|
||||
switch (status) {
|
||||
case "missing_env":
|
||||
return {
|
||||
title: "Skyvern API key missing",
|
||||
description:
|
||||
"All requests from the UI to the local backend will fail until a valid key is configured.",
|
||||
};
|
||||
case "invalid_format":
|
||||
return {
|
||||
title: "Skyvern API key is invalid",
|
||||
description:
|
||||
"The configured key cannot be decoded. Regenerate a new key to continue using the UI.",
|
||||
};
|
||||
case "invalid":
|
||||
return {
|
||||
title: "Skyvern API key not recognized",
|
||||
description:
|
||||
"The backend rejected the configured key. Regenerate it to refresh local auth.",
|
||||
};
|
||||
case "expired":
|
||||
return {
|
||||
title: "Skyvern API key expired",
|
||||
description:
|
||||
"The current key is no longer valid. Generate a fresh key to restore connectivity.",
|
||||
};
|
||||
case "not_found":
|
||||
return {
|
||||
title: "Local organization missing",
|
||||
description:
|
||||
"The backend could not find the Skyvern-local organization. Regenerate the key to recreate it.",
|
||||
};
|
||||
case "error":
|
||||
default:
|
||||
return {
|
||||
title: "Unable to verify Skyvern API key",
|
||||
description:
|
||||
"The UI could not reach the diagnostics endpoint. Ensure the backend is running locally.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function SelfHealApiKeyBanner() {
|
||||
const diagnosticsQuery = useAuthDiagnostics();
|
||||
const { toast } = useToast();
|
||||
const [isRepairing, setIsRepairing] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const isProductionBuild = !import.meta.env.DEV;
|
||||
|
||||
const { data, error, isLoading, refetch } = diagnosticsQuery;
|
||||
|
||||
const rawStatus = data?.status;
|
||||
const bannerStatus: BannerStatus | null = error
|
||||
? "error"
|
||||
: rawStatus && rawStatus !== "ok"
|
||||
? rawStatus
|
||||
: null;
|
||||
|
||||
if (!bannerStatus && !errorMessage) {
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const copy = getCopy(bannerStatus ?? "missing_env");
|
||||
const queryErrorMessage = error?.message ?? null;
|
||||
|
||||
const handleRepair = async () => {
|
||||
setIsRepairing(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const client = await getClient(null);
|
||||
const response = await client.post<{
|
||||
fingerprint?: string;
|
||||
api_key?: string;
|
||||
backend_env_path?: string;
|
||||
frontend_env_path?: string;
|
||||
}>("/internal/auth/repair");
|
||||
|
||||
const {
|
||||
fingerprint,
|
||||
api_key: apiKey,
|
||||
backend_env_path: backendEnvPath,
|
||||
frontend_env_path: frontendEnvPath,
|
||||
} = response.data;
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error("Repair succeeded but no API key was returned.");
|
||||
}
|
||||
|
||||
setApiKeyHeader(apiKey);
|
||||
|
||||
const fingerprintSuffix = fingerprint
|
||||
? ` (fingerprint ${fingerprint})`
|
||||
: "";
|
||||
|
||||
const pathsElements = [];
|
||||
if (backendEnvPath) {
|
||||
pathsElements.push(<div key="backend">Backend: {backendEnvPath}</div>);
|
||||
}
|
||||
if (frontendEnvPath) {
|
||||
pathsElements.push(
|
||||
<div key="frontend">Frontend: {frontendEnvPath}</div>,
|
||||
);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "API key regenerated",
|
||||
description: (
|
||||
<div>
|
||||
<div>
|
||||
Requests now use the updated key automatically{fingerprintSuffix}{" "}
|
||||
persisted to sessionStorage and written to the following .env
|
||||
paths:
|
||||
</div>
|
||||
{pathsElements.length > 0 && (
|
||||
<div className="mt-2 space-y-2">{pathsElements}</div>
|
||||
)}
|
||||
{isProductionBuild && (
|
||||
<div className="mt-3">
|
||||
Restart the UI server for more robust API key persistence.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
await refetch({ throwOnError: false });
|
||||
} catch (fetchError) {
|
||||
const message =
|
||||
fetchError instanceof Error
|
||||
? fetchError.message
|
||||
: "Unable to repair API key";
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setIsRepairing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-4">
|
||||
<Alert className="flex flex-col items-center gap-2 border-slate-700 bg-slate-900 text-slate-50">
|
||||
<AlertTitle className="text-center text-base font-semibold tracking-wide">
|
||||
{copy.title}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="space-y-3 text-center text-sm leading-6">
|
||||
{bannerStatus !== "error" ? (
|
||||
<>
|
||||
<p>
|
||||
{copy.description} Update <code>VITE_SKYVERN_API_KEY</code> in{" "}
|
||||
<code className="mx-1">skyvern-frontend/.env</code>
|
||||
by running <code>skyvern init</code> or click the button below
|
||||
to regenerate it automatically.
|
||||
</p>
|
||||
{isProductionBuild && (
|
||||
<p className="text-yellow-300">
|
||||
When running a production build, the regenerated API key is
|
||||
stored in sessionStorage. Closing this tab or browser window
|
||||
will lose the key. Restart the UI server for more robust
|
||||
persistence.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
onClick={handleRepair}
|
||||
disabled={isRepairing}
|
||||
variant="secondary"
|
||||
>
|
||||
{isRepairing ? "Regenerating…" : "Regenerate API key"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p>{copy.description}</p>
|
||||
)}
|
||||
{errorMessage ? (
|
||||
<p className="text-xs text-rose-200">{errorMessage}</p>
|
||||
) : null}
|
||||
{queryErrorMessage && !errorMessage ? (
|
||||
<p className="text-xs text-rose-200">{queryErrorMessage}</p>
|
||||
) : null}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { SelfHealApiKeyBanner };
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCredentialGetter } from "./useCredentialGetter";
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import { envCredential } from "@/util/env";
|
||||
import { getRuntimeApiKey } from "@/util/env";
|
||||
import { ApiKeyApiResponse, OrganizationApiResponse } from "@/api/types";
|
||||
|
||||
function useApiCredential() {
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const credentialsFromEnv = envCredential;
|
||||
const credentialsFromEnv = getRuntimeApiKey();
|
||||
|
||||
const { data: organizations } = useQuery<Array<OrganizationApiResponse>>({
|
||||
queryKey: ["organizations"],
|
||||
@@ -16,7 +16,7 @@ function useApiCredential() {
|
||||
.get("/organizations/")
|
||||
.then((response) => response.data.organizations);
|
||||
},
|
||||
enabled: envCredential === null,
|
||||
enabled: credentialsFromEnv === null,
|
||||
});
|
||||
|
||||
const organization = organizations?.[0];
|
||||
|
||||
46
skyvern-frontend/src/hooks/useAuthDiagnostics.ts
Normal file
46
skyvern-frontend/src/hooks/useAuthDiagnostics.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
|
||||
export type AuthStatusValue =
|
||||
| "missing_env"
|
||||
| "invalid_format"
|
||||
| "invalid"
|
||||
| "expired"
|
||||
| "not_found"
|
||||
| "ok";
|
||||
|
||||
export type AuthDiagnosticsResponse = {
|
||||
status: AuthStatusValue;
|
||||
fingerprint?: string;
|
||||
organization_id?: string;
|
||||
expires_at?: number;
|
||||
api_key?: string;
|
||||
};
|
||||
|
||||
async function fetchDiagnostics(): Promise<AuthDiagnosticsResponse> {
|
||||
const client = await getClient(null);
|
||||
try {
|
||||
const response = await client.get<AuthDiagnosticsResponse>(
|
||||
"/internal/auth/status",
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
return { status: "ok" };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function useAuthDiagnostics() {
|
||||
return useQuery<AuthDiagnosticsResponse, Error>({
|
||||
queryKey: ["internal", "auth", "status"],
|
||||
queryFn: fetchDiagnostics,
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export { useAuthDiagnostics };
|
||||
@@ -5,6 +5,7 @@ import { Outlet } from "react-router-dom";
|
||||
import { Header } from "./Header";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { useDebugStore } from "@/store/useDebugStore";
|
||||
import { SelfHealApiKeyBanner } from "@/components/SelfHealApiKeyBanner";
|
||||
|
||||
function RootLayout() {
|
||||
const collapsed = useSidebarStore((state) => state.collapsed);
|
||||
@@ -12,15 +13,21 @@ function RootLayout() {
|
||||
const isEmbedded = embed === "true";
|
||||
const debugStore = useDebugStore();
|
||||
|
||||
const horizontalPadding = cn("lg:pl-64", {
|
||||
"lg:pl-28": collapsed,
|
||||
"lg:pl-4": isEmbedded,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isEmbedded && <Sidebar />}
|
||||
<div className="h-full w-full">
|
||||
<div className={horizontalPadding}>
|
||||
<SelfHealApiKeyBanner />
|
||||
</div>
|
||||
<Header />
|
||||
<main
|
||||
className={cn("lg:pb-4 lg:pl-64", {
|
||||
"lg:pl-28": collapsed,
|
||||
"lg:pl-4": isEmbedded,
|
||||
className={cn("lg:pb-4", horizontalPadding, {
|
||||
"lg:pb-0": debugStore.isDebugMode,
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { envCredential } from "@/util/env";
|
||||
import { getRuntimeApiKey } from "@/util/env";
|
||||
import { HiddenCopyableInput } from "@/components/ui/hidden-copyable-input";
|
||||
import { OnePasswordTokenForm } from "@/components/OnePasswordTokenForm";
|
||||
import { AzureClientSecretCredentialTokenForm } from "@/components/AzureClientSecretCredentialTokenForm";
|
||||
@@ -22,7 +22,7 @@ import { AzureClientSecretCredentialTokenForm } from "@/components/AzureClientSe
|
||||
function Settings() {
|
||||
const { environment, organization, setEnvironment, setOrganization } =
|
||||
useSettingsStore();
|
||||
const apiKey = envCredential;
|
||||
const apiKey = getRuntimeApiKey();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { toast } from "@/components/ui/use-toast";
|
||||
import { ZoomableImage } from "@/components/ZoomableImage";
|
||||
import { useCostCalculator } from "@/hooks/useCostCalculator";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { envCredential } from "@/util/env";
|
||||
import { getRuntimeApiKey } from "@/util/env";
|
||||
import {
|
||||
keepPreviousData,
|
||||
useQuery,
|
||||
@@ -79,7 +79,8 @@ function TaskActions() {
|
||||
const token = await credentialGetter();
|
||||
credential = `?token=Bearer ${token}`;
|
||||
} else {
|
||||
credential = `?apikey=${envCredential}`;
|
||||
const apiKey = getRuntimeApiKey();
|
||||
credential = apiKey ? `?apikey=${apiKey}` : "";
|
||||
}
|
||||
if (socket) {
|
||||
socket.close();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useEffect, useState } from "react";
|
||||
import { statusIsNotFinalized } from "@/routes/tasks/types";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { envCredential } from "@/util/env";
|
||||
import { getRuntimeApiKey } from "@/util/env";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
@@ -45,7 +45,8 @@ function WorkflowRunStream(props?: Props) {
|
||||
const token = await credentialGetter();
|
||||
credential = `?token=Bearer ${token}`;
|
||||
} else {
|
||||
credential = `?apikey=${envCredential}`;
|
||||
const apiKey = getRuntimeApiKey();
|
||||
credential = apiKey ? `?apikey=${apiKey}` : "";
|
||||
}
|
||||
if (socket) {
|
||||
socket.close();
|
||||
|
||||
@@ -10,8 +10,10 @@ if (!environment) {
|
||||
console.warn("environment environment variable was not set");
|
||||
}
|
||||
|
||||
const envCredential: string | null =
|
||||
import.meta.env.VITE_SKYVERN_API_KEY ?? null;
|
||||
const buildTimeApiKey: string | null =
|
||||
typeof import.meta.env.VITE_SKYVERN_API_KEY === "string"
|
||||
? import.meta.env.VITE_SKYVERN_API_KEY
|
||||
: null;
|
||||
|
||||
const artifactApiBaseUrl = import.meta.env.VITE_ARTIFACT_API_BASE_URL;
|
||||
|
||||
@@ -21,8 +23,11 @@ if (!artifactApiBaseUrl) {
|
||||
|
||||
const apiPathPrefix = import.meta.env.VITE_API_PATH_PREFIX ?? "";
|
||||
|
||||
const API_KEY_STORAGE_KEY = "skyvern.apiKey";
|
||||
|
||||
const lsKeys = {
|
||||
browserSessionId: "skyvern.browserSessionId",
|
||||
apiKey: API_KEY_STORAGE_KEY,
|
||||
};
|
||||
|
||||
const wssBaseUrl = import.meta.env.VITE_WSS_BASE_URL;
|
||||
@@ -38,13 +43,53 @@ try {
|
||||
newWssBaseUrl = wssBaseUrl.replace("/api", "");
|
||||
}
|
||||
|
||||
let runtimeApiKey: string | null | undefined;
|
||||
|
||||
function readPersistedApiKey(): string | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return window.sessionStorage.getItem(API_KEY_STORAGE_KEY);
|
||||
}
|
||||
|
||||
function getRuntimeApiKey(): string | null {
|
||||
if (runtimeApiKey !== undefined) {
|
||||
return runtimeApiKey;
|
||||
}
|
||||
|
||||
const persisted = readPersistedApiKey();
|
||||
const candidate = persisted ?? buildTimeApiKey;
|
||||
|
||||
// Treat YOUR_API_KEY as missing. We may inherit this from .env.example
|
||||
// in some cases of misconfiguration.
|
||||
runtimeApiKey = candidate === "YOUR_API_KEY" ? null : candidate;
|
||||
return runtimeApiKey;
|
||||
}
|
||||
|
||||
function persistRuntimeApiKey(value: string): void {
|
||||
runtimeApiKey = value;
|
||||
if (typeof window !== "undefined") {
|
||||
window.sessionStorage.setItem(API_KEY_STORAGE_KEY, value);
|
||||
}
|
||||
}
|
||||
|
||||
function clearRuntimeApiKey(): void {
|
||||
runtimeApiKey = null;
|
||||
if (typeof window !== "undefined") {
|
||||
window.sessionStorage.removeItem(API_KEY_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
apiBaseUrl,
|
||||
environment,
|
||||
envCredential,
|
||||
artifactApiBaseUrl,
|
||||
apiPathPrefix,
|
||||
lsKeys,
|
||||
wssBaseUrl,
|
||||
newWssBaseUrl,
|
||||
getRuntimeApiKey,
|
||||
persistRuntimeApiKey,
|
||||
clearRuntimeApiKey,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user