diff --git a/packages/core/src/__tests__/core-headers.test.ts b/packages/core/src/__tests__/core-headers.test.ts index e63791bea0..0a0f62477d 100644 --- a/packages/core/src/__tests__/core-headers.test.ts +++ b/packages/core/src/__tests__/core-headers.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CopilotKitCore } from "../core"; +import { ProxiedCopilotRuntimeAgent } from "../agent"; import { HttpAgent } from "@ag-ui/client"; import { waitForCondition } from "./test-utils"; @@ -29,6 +30,8 @@ describe("CopilotKitCore headers", () => { it("includes provided headers when fetching runtime info", async () => { const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, json: vi.fn().mockResolvedValue({ version: "1.0.0", agents: {} }), }); global.fetch = fetchMock as unknown as typeof fetch; @@ -275,4 +278,122 @@ describe("CopilotKitCore headers", () => { }), ); }); + + it("drops headers whose value is null or undefined", () => { + const core = new CopilotKitCore({ + headers: { Authorization: "Bearer initial", "X-Trace": "abc" }, + }); + + core.setHeaders({ + Authorization: null, + "X-Trace": undefined, + "X-Keep": "kept", + }); + + expect(core.headers).toEqual({ "X-Keep": "kept" }); + expect("Authorization" in core.headers).toBe(false); + expect("X-Trace" in core.headers).toBe(false); + }); + + it("clears a single header while preserving the rest via spread", () => { + const core = new CopilotKitCore({ + headers: { Authorization: "Bearer token", "X-Team": "angular" }, + }); + + // Logout pattern: spread current headers, clear just Authorization. + core.setHeaders({ ...core.headers, Authorization: null }); + + expect(core.headers).toEqual({ "X-Team": "angular" }); + }); + + it("overwrites rather than merges — keys absent from the new set are dropped", () => { + const agent = new HttpAgent({ url: "https://runtime.example" }); + const core = new CopilotKitCore({ + headers: { Authorization: "Bearer token", "X-Team": "angular" }, + agents__unsafe_dev_only: { default: agent }, + }); + + // Without spreading the existing headers, "X-Team" is not carried over. + core.setHeaders({ Authorization: "Bearer updated" }); + + expect(core.headers).toEqual({ Authorization: "Bearer updated" }); + expect("X-Team" in agent.headers).toBe(false); + }); + + it("notifies subscribers with the cleared header set", () => { + const core = new CopilotKitCore({ + headers: { Authorization: "Bearer token" }, + }); + + const onHeadersChanged = vi.fn(); + core.subscribe({ onHeadersChanged }); + + core.setHeaders({ Authorization: null }); + + expect(onHeadersChanged).toHaveBeenCalledWith( + expect.objectContaining({ headers: {} }), + ); + }); + + it("clears the header on registered agents when set to null", () => { + const core = new CopilotKitCore({ + agents__unsafe_dev_only: { + local: new HttpAgent({ url: "https://runtime.example" }), + }, + headers: { Authorization: "Bearer token" }, + }); + + const agent = core.getAgent("local") as HttpAgent; + expect(agent.headers).toMatchObject({ Authorization: "Bearer token" }); + + core.setHeaders({ Authorization: null }); + + expect("Authorization" in agent.headers).toBe(false); + }); + + it("keeps an empty-string header value rather than dropping the key", () => { + const core = new CopilotKitCore({ headers: { Authorization: "Bearer x" } }); + + // Only null/undefined clear a header; "" is a valid value and survives. + core.setHeaders({ Authorization: "" }); + + expect(core.headers).toEqual({ Authorization: "" }); + }); + + it("clears the header on remote agents fetched from runtime info", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ + version: "1.0.0", + agents: { + remote: { + name: "Remote Agent", + className: "RemoteClass", + description: "Remote description", + }, + }, + }), + }); + global.fetch = fetchMock as unknown as typeof fetch; + + const core = new CopilotKitCore({ + runtimeUrl: "https://runtime.example", + headers: { Authorization: "Bearer token" }, + }); + + await waitForCondition(() => core.getAgent("remote") !== undefined); + + const remoteAgent = core.getAgent("remote") as HttpAgent; + // Remote agents are ProxiedCopilotRuntimeAgent (which extends HttpAgent), + // so the clear propagates to them the same way it does to local agents. + expect(remoteAgent).toBeInstanceOf(ProxiedCopilotRuntimeAgent); + expect(remoteAgent.headers).toMatchObject({ + Authorization: "Bearer token", + }); + + core.setHeaders({ Authorization: null }); + + expect("Authorization" in remoteAgent.headers).toBe(false); + }); }); diff --git a/packages/core/src/core/core.ts b/packages/core/src/core/core.ts index 855d4f3e90..53dac95b66 100644 --- a/packages/core/src/core/core.ts +++ b/packages/core/src/core/core.ts @@ -323,6 +323,22 @@ export interface CopilotKitCoreFriendsAccess { waitForPendingFrameworkUpdates(): Promise; } +/** + * Normalize a header map to the internal invariant: a `Record` + * with no `null`/`undefined` values. Entries whose value is `null`/`undefined` + * are dropped (this is how a header is cleared). Shared by the constructor and + * `setHeaders` so both write paths into `_headers` enforce the same invariant. + */ +function normalizeHeaders( + headers: Record, +): Record { + return Object.fromEntries( + Object.entries(headers).filter( + (entry): entry is [string, string] => entry[1] != null, + ), + ); +} + export class CopilotKitCore { private _headers: Record; private _credentials?: RequestCredentials; @@ -358,7 +374,7 @@ export class CopilotKitCore { suggestionsConfig = [], debug, }: CopilotKitCoreConfig) { - this._headers = headers; + this._headers = normalizeHeaders(headers); this._credentials = credentials; this._properties = properties; this._debug = debug; @@ -645,8 +661,27 @@ export class CopilotKitCore { /** * Configuration updates */ - setHeaders(headers: Record): void { - this._headers = headers; + /** + * Replace the headers sent with every runtime request. + * + * This is an overwrite, not a merge — the supplied object becomes the + * complete header set. Entries whose value is `null` or `undefined` are + * dropped, which is how you clear a header (e.g. removing `Authorization` + * on logout) without leaving an empty-string value behind: + * + * ```ts + * copilotkit.setHeaders({ + * ...copilotkit.headers, + * Authorization: token ? `Bearer ${token}` : null, + * }); + * ``` + * + * The resulting header set is also re-applied to every registered + * `HttpAgent`-derived agent (replacing its `headers`) and `onHeadersChanged` + * subscribers are notified. + */ + setHeaders(headers: Record): void { + this._headers = normalizeHeaders(headers); this.agentRegistry.applyHeadersToAgents( this.agentRegistry.agents as Record, ); diff --git a/packages/react-core/skills/react-core/references/provider-setup.md b/packages/react-core/skills/react-core/references/provider-setup.md index 96352293e5..91c186c2ed 100644 --- a/packages/react-core/skills/react-core/references/provider-setup.md +++ b/packages/react-core/skills/react-core/references/provider-setup.md @@ -100,15 +100,33 @@ of re-rendering the provider with a new `headers` prop. import { useCopilotKit } from "@copilotkit/react-core/v2"; import { useEffect } from "react"; -export function AuthTokenSync({ token }: { token: string }) { +export function AuthTokenSync({ token }: { token: string | null }) { const { copilotkit } = useCopilotKit(); useEffect(() => { - copilotkit.setHeaders({ Authorization: `Bearer ${token}` }); + // setHeaders is an overwrite, not a merge — spread the current headers so + // entries set elsewhere (e.g. the public license key) survive. A `null` + // value clears that header, so logging out removes `Authorization` instead + // of sending an empty one. + copilotkit.setHeaders({ + ...copilotkit.headers, + Authorization: token ? `Bearer ${token}` : null, + }); }, [copilotkit, token]); return null; } ``` +`setHeaders` accepts `null`/`undefined` values and drops those keys, so passing +`Authorization: null` is the supported way to clear a header. Setting it to an +empty string would keep the header present with a blank value. + +Do not set the same header through both the `headers` prop and imperative +`setHeaders`. Whenever any provider prop changes, the provider calls +`setHeaders` with its prop-derived headers — a full overwrite that drops every +imperatively-set header, not just keys the prop also defines. Keep rotating +values like the auth token out of the `headers` prop and manage them only +through `setHeaders` (as above). + ### Global error handler `onError` fires for every `CopilotKitCoreErrorCode` emitted by core. Keeps diff --git a/showcase/shell-docs/src/content/reference/core/classes/CopilotKitCore.mdx b/showcase/shell-docs/src/content/reference/core/classes/CopilotKitCore.mdx index af41523936..b8d3e63cb7 100644 --- a/showcase/shell-docs/src/content/reference/core/classes/CopilotKitCore.mdx +++ b/showcase/shell-docs/src/content/reference/core/classes/CopilotKitCore.mdx @@ -169,8 +169,8 @@ new CopilotKitCore(config: CopilotKitCoreConfig) Updates the transport style. - - Replaces the request headers and re-applies them to existing agents. + + Replaces the request headers and re-applies them to existing agents. Any key whose value is `null` or `undefined` is dropped from the stored headers, which is the supported way to clear a header (e.g. `Authorization` on logout). An empty string remains a valid header value and is preserved. diff --git a/skills/react-core/references/provider-setup.md b/skills/react-core/references/provider-setup.md index 96352293e5..91c186c2ed 100644 --- a/skills/react-core/references/provider-setup.md +++ b/skills/react-core/references/provider-setup.md @@ -100,15 +100,33 @@ of re-rendering the provider with a new `headers` prop. import { useCopilotKit } from "@copilotkit/react-core/v2"; import { useEffect } from "react"; -export function AuthTokenSync({ token }: { token: string }) { +export function AuthTokenSync({ token }: { token: string | null }) { const { copilotkit } = useCopilotKit(); useEffect(() => { - copilotkit.setHeaders({ Authorization: `Bearer ${token}` }); + // setHeaders is an overwrite, not a merge — spread the current headers so + // entries set elsewhere (e.g. the public license key) survive. A `null` + // value clears that header, so logging out removes `Authorization` instead + // of sending an empty one. + copilotkit.setHeaders({ + ...copilotkit.headers, + Authorization: token ? `Bearer ${token}` : null, + }); }, [copilotkit, token]); return null; } ``` +`setHeaders` accepts `null`/`undefined` values and drops those keys, so passing +`Authorization: null` is the supported way to clear a header. Setting it to an +empty string would keep the header present with a blank value. + +Do not set the same header through both the `headers` prop and imperative +`setHeaders`. Whenever any provider prop changes, the provider calls +`setHeaders` with its prop-derived headers — a full overwrite that drops every +imperatively-set header, not just keys the prop also defines. Keep rotating +values like the auth token out of the `headers` prop and manage them only +through `setHeaders` (as above). + ### Global error handler `onError` fires for every `CopilotKitCoreErrorCode` emitted by core. Keeps