fix(ui): send the agent form's credentials when the admin clicks Discover

The Add Agent form's discovery panel sent no credentials, so an agent
that requires a token answered 401. Clicking Discover now sends the
LangGraph API Key field and the A2A static headers, never the redacted
placeholder a saved key reads back as. Automatic discovery while the
admin types stays anonymous, so credentials never reach a half-typed
URL.
This commit is contained in:
shrey kharbanda 2026-11-03 10:35:09 -08:00
parent bf1bd16926
commit 647cb46fe3
6 changed files with 169 additions and 72 deletions

View file

@ -189,6 +189,34 @@ describe("AgentCardDiscovery", () => {
});
});
it("sends the credentials only when the admin clicks Discover", async () => {
mockDiscover.mockRejectedValueOnce(new Error("HTTP 401")).mockResolvedValue({
url: "https://lg.example.com",
agent_card: sampleCard,
});
const plan = {
url: "https://lg.example.com",
discovery_mode: "langgraph_platform" as const,
params: { assistant_id: "asst_1" },
api_key: "sk-1",
static_headers: { "x-tenant": "t1" },
};
renderWithProviders(<AgentCardDiscovery accessToken="tok" onApply={vi.fn()} discoveryRequest={plan} />);
await vi.advanceTimersByTimeAsync(500);
expect(await screen.findByText(/HTTP 401/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Discover/ }));
await waitFor(() => expect(mockDiscover).toHaveBeenCalledTimes(2));
const anonymous = { discovery_mode: "langgraph_platform", params: { assistant_id: "asst_1" } };
expect(mockDiscover).toHaveBeenNthCalledWith(1, "tok", "https://lg.example.com", anonymous);
expect(mockDiscover).toHaveBeenNthCalledWith(2, "tok", "https://lg.example.com", {
...anonymous,
api_key: "sk-1",
static_headers: { "x-tenant": "t1" },
});
});
it("disables Re-discover until the parent provides a usable URL", async () => {
renderWithProviders(
<AgentCardDiscovery

View file

@ -118,65 +118,73 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
[discoveryRequest?.params],
);
const handleDiscover = useCallback(async () => {
if (!accessToken) {
setError(i18n.t("agents.cardDiscovery.errors.noAccessToken"));
onApplyRef.current(null);
return;
}
const trimmed = effectiveUrl.trim();
if (!trimmed) {
setError(
isParentDriven
? i18n.t("agents.cardDiscovery.errors.fillConnectionDetails")
: i18n.t("agents.cardDiscovery.errors.enterBaseUrl"),
);
setCard(null);
onApplyRef.current(null);
return;
}
const currentDiscoveryRequest = discoveryRequestRef.current;
const requestId = ++discoverRequestIdRef.current;
setLoading(true);
setError(null);
try {
const response = await discoverAgentCardCall(
accessToken,
trimmed,
isParentDriven && currentDiscoveryRequest
? {
discovery_mode: currentDiscoveryRequest.discovery_mode,
params: currentDiscoveryRequest.params,
}
: undefined,
);
if (requestId !== discoverRequestIdRef.current) return;
lastSyncedSelectionRef.current = null;
setCard(response.agent_card);
resetSelections(response.agent_card);
} catch (e: any) {
if (requestId !== discoverRequestIdRef.current) return;
setError(e?.message ? String(e.message) : i18n.t("agents.cardDiscovery.errors.discoverFailed"));
setCard(null);
lastSyncedSelectionRef.current = null;
onApplyRef.current(null);
} finally {
if (requestId === discoverRequestIdRef.current) {
setLoading(false);
// Credentials go only with an explicit Discover, never to a URL the admin is still typing.
const handleDiscover = useCallback(
async (withCredentials?: boolean) => {
if (!accessToken) {
setError(i18n.t("agents.cardDiscovery.errors.noAccessToken"));
onApplyRef.current(null);
return;
}
}
// ``discoveryMode`` / ``discoveryParamsKey`` are primitive proxies for
// ``discoveryRequest`` content; the actual object is read via the ref
// above so identity churn from the parent doesn't recreate this callback.
// ``savedAgentCard`` is intentionally NOT a dep — it's read via
// ``savedAgentCardRef`` inside ``resetSelections``. Including it here
// would recreate this callback whenever the parent hands us a new
// ``savedAgentCard`` object (e.g. a background refresh of agent data
// during editing), which would re-fire the auto-discover effect and
// wipe in-progress user selections.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [accessToken, effectiveUrl, isParentDriven, discoveryMode, discoveryParamsKey]);
const trimmed = effectiveUrl.trim();
if (!trimmed) {
setError(
isParentDriven
? i18n.t("agents.cardDiscovery.errors.fillConnectionDetails")
: i18n.t("agents.cardDiscovery.errors.enterBaseUrl"),
);
setCard(null);
onApplyRef.current(null);
return;
}
const currentDiscoveryRequest = discoveryRequestRef.current;
const requestId = ++discoverRequestIdRef.current;
setLoading(true);
setError(null);
try {
const response = await discoverAgentCardCall(
accessToken,
trimmed,
isParentDriven && currentDiscoveryRequest
? {
discovery_mode: currentDiscoveryRequest.discovery_mode,
params: currentDiscoveryRequest.params,
...(withCredentials && {
api_key: currentDiscoveryRequest.api_key,
static_headers: currentDiscoveryRequest.static_headers,
}),
}
: undefined,
);
if (requestId !== discoverRequestIdRef.current) return;
lastSyncedSelectionRef.current = null;
setCard(response.agent_card);
resetSelections(response.agent_card);
} catch (e: any) {
if (requestId !== discoverRequestIdRef.current) return;
setError(e?.message ? String(e.message) : i18n.t("agents.cardDiscovery.errors.discoverFailed"));
setCard(null);
lastSyncedSelectionRef.current = null;
onApplyRef.current(null);
} finally {
if (requestId === discoverRequestIdRef.current) {
setLoading(false);
}
}
// ``discoveryMode`` / ``discoveryParamsKey`` are primitive proxies for
// ``discoveryRequest`` content; the actual object is read via the ref
// above so identity churn from the parent doesn't recreate this callback.
// ``savedAgentCard`` is intentionally NOT a dep — it's read via
// ``savedAgentCardRef`` inside ``resetSelections``. Including it here
// would recreate this callback whenever the parent hands us a new
// ``savedAgentCard`` object (e.g. a background refresh of agent data
// during editing), which would re-fire the auto-discover effect and
// wipe in-progress user selections.
// eslint-disable-next-line react-hooks/exhaustive-deps
},
[accessToken, effectiveUrl, isParentDriven, discoveryMode, discoveryParamsKey],
);
const debouncedDiscover = useDebouncedCallback(
() => {
@ -283,7 +291,7 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
)}
</div>
<div className="flex justify-end">
<Button onClick={handleDiscover} disabled={loading || !effectiveUrl.trim()}>
<Button onClick={() => handleDiscover(true)} disabled={loading || !effectiveUrl.trim()}>
{renderDiscoverIcon()}
{discoverLabel}
</Button>
@ -312,7 +320,7 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
}}
disabled={loading}
/>
<Button onClick={handleDiscover} disabled={loading}>
<Button onClick={() => handleDiscover()} disabled={loading}>
{renderDiscoverIcon()}
{discoverLabel}
</Button>

View file

@ -303,16 +303,9 @@ export const buildAgentDataFromForm = (values: any, existingAgent?: any) => {
if (values.rpm_limit != null) agentData.rpm_limit = values.rpm_limit;
if (values.session_tpm_limit != null) agentData.session_tpm_limit = values.session_tpm_limit;
if (values.session_rpm_limit != null) agentData.session_rpm_limit = values.session_rpm_limit;
// static_headers: convert [{header, value}, ...] → {header: value, ...}
if (Array.isArray(values.static_headers) && values.static_headers.length > 0) {
const staticHeaders: Record<string, string> = {};
values.static_headers.forEach((entry: { header?: string; value?: string }) => {
const key = entry?.header?.trim();
if (key) staticHeaders[key] = entry?.value ?? "";
});
if (Object.keys(staticHeaders).length > 0) {
agentData.static_headers = staticHeaders;
}
const staticHeaders = staticHeadersFromForm(values.static_headers);
if (Object.keys(staticHeaders).length > 0) {
agentData.static_headers = staticHeaders;
}
// extra_headers: already an array of strings from Select tags
@ -325,6 +318,18 @@ export const buildAgentDataFromForm = (values: any, existingAgent?: any) => {
return agentData;
};
/** Converts the form's [{header, value}, ...] rows to {header: value, ...}, skipping rows without a name. */
export const staticHeadersFromForm = (rows: unknown): Record<string, string> => {
const staticHeaders: Record<string, string> = {};
if (Array.isArray(rows)) {
rows.forEach((entry: { header?: string; value?: string }) => {
const key = entry?.header?.trim();
if (key) staticHeaders[key] = entry?.value ?? "";
});
}
return staticHeaders;
};
export const applyKillSwitchToPayload = (
agentData: { kill_switch?: KillSwitchConfig | null },
form: KillSwitchFormValue | undefined,

View file

@ -1,5 +1,10 @@
import { describe, it, expect } from "vitest";
import { selectionsFromSavedAgentCard, selectionsFromUpstreamCard, skillId } from "./agent_discovery_utils";
import {
buildDiscoveryRequest,
selectionsFromSavedAgentCard,
selectionsFromUpstreamCard,
skillId,
} from "./agent_discovery_utils";
const upstreamCard = {
name: "Upstream Agent",
@ -50,3 +55,44 @@ describe("selectionsFromUpstreamCard", () => {
expect(result.editedName).toBe("Upstream Agent");
});
});
describe("buildDiscoveryRequest", () => {
it("sends the LangGraph API key typed into the form", () => {
const values = { api_base: "https://lg.example.com/", assistant_id: "asst_1", api_key: "sk-1" };
const plan = {
url: "https://lg.example.com",
discovery_mode: "langgraph_platform",
params: { assistant_id: "asst_1" },
display_url: "https://lg.example.com/.well-known/agent-card.json?assistant_id=asst_1",
api_key: "sk-1",
};
expect(buildDiscoveryRequest("langgraph", values)).toEqual(plan);
});
it("sends no key when the edit form holds the redacted saved one", () => {
const values = { api_base: "https://lg.example.com", assistant_id: "asst_1", api_key: "REDACTED_BY_LITELM" };
expect(buildDiscoveryRequest("langgraph", values)).not.toHaveProperty("api_key");
});
it("sends an A2A agent's named static headers", () => {
const values = {
url: "https://agent.example.com",
static_headers: [
{ header: " Authorization ", value: "Basic abc" },
{ header: " ", value: "orphan" },
],
};
const plan = {
url: "https://agent.example.com",
discovery_mode: "well_known_fallback",
display_url: "https://agent.example.com/.well-known/agent-card.json",
static_headers: { Authorization: "Basic abc" },
};
expect(buildDiscoveryRequest("a2a", values)).toEqual(plan);
});
});

View file

@ -1,12 +1,17 @@
import { AgentCreateInfo, DiscoveredAgentCard, DiscoveryMode } from "@/components/networking";
import { staticHeadersFromForm } from "./agent_config";
export interface DiscoveryRequestPlan {
url: string;
discovery_mode: DiscoveryMode;
params?: Record<string, any>;
display_url?: string;
api_key?: string;
static_headers?: Record<string, string>;
}
const REDACTED_SECRET = "REDACTED_BY_LITELM";
export const skillId = (skill: any, idx: number): string => skill?.id ?? skill?.name ?? `skill-${idx}`;
export const ALLOWED_CAPABILITY_KEYS = ["streaming"] as const;
@ -137,21 +142,26 @@ export const buildDiscoveryRequest = (
const assistantId = trim(values.assistant_id);
if (!base || !assistantId) return undefined;
const query = `?assistant_id=${encodeURIComponent(assistantId)}`;
const apiKey = typeof values.api_key === "string" ? values.api_key : "";
return {
url: base,
discovery_mode: "langgraph_platform",
params: { assistant_id: assistantId },
display_url: `${base}/.well-known/agent-card.json${query}`,
// A saved key reads back redacted, so only a key typed into the form is sent.
...(apiKey.trim() && apiKey !== REDACTED_SECRET ? { api_key: apiKey } : {}),
};
}
if (agentType === "a2a" || selectedAgentTypeInfo?.use_a2a_form_fields) {
const base = stripTrailingSlash(trim(values.url));
if (!base) return undefined;
const staticHeaders = staticHeadersFromForm(values.static_headers);
return {
url: base,
discovery_mode: "well_known_fallback",
display_url: `${base}/.well-known/agent-card.json`,
...(Object.keys(staticHeaders).length > 0 ? { static_headers: staticHeaders } : {}),
};
}

View file

@ -4353,7 +4353,7 @@ type DiscoverAgentCardResponse = Omit<components["schemas"]["DiscoverAgentRespon
export type DiscoveryMode = components["schemas"]["DiscoveryMode"];
type DiscoverAgentCardOptions = Partial<
Pick<components["schemas"]["DiscoverAgentRequest"], "discovery_mode" | "params">
Pick<components["schemas"]["DiscoverAgentRequest"], "discovery_mode" | "params" | "api_key" | "static_headers">
>;
export const discoverAgentCardCall = async (