fix(core): allow clearing headers via setHeaders with null/undefined

setHeaders typed headers as Record<string, string>, so there was no
type-safe way to clear a header (e.g. Authorization on logout) — passing
an empty string left the header present with a blank value.

Widen the signature to Record<string, string | null | undefined> and drop
any entry whose value is null/undefined. setHeaders remains a full overwrite,
so clearing one header while keeping the rest is the spread pattern:
setHeaders({ ...copilotkit.headers, Authorization: null }). A shared
normalizeHeaders helper enforces the same string-only invariant at both
write paths (constructor and setHeaders).

Update the react-core AuthTokenSync skill example to show the logout/clear
path and warn that a header must not be managed via both the headers prop and
imperative setHeaders (the provider re-applies prop-derived headers as a full
overwrite when its inputs change). Also update the setHeaders reference
signature docs. Tests cover null/undefined stripping, empty-string
preservation, overwrite-not-merge semantics, single-header clear via spread,
subscriber notification, and propagation to local and remote
(ProxiedCopilotRuntimeAgent) agents.

Fixes #5535
This commit is contained in:
Austin Merrick
2026-06-19 14:13:02 -07:00
parent b585b33947
commit 4c71ea1138
5 changed files with 201 additions and 9 deletions
@@ -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);
});
});
+38 -3
View File
@@ -323,6 +323,22 @@ export interface CopilotKitCoreFriendsAccess {
waitForPendingFrameworkUpdates(): Promise<void>;
}
/**
* Normalize a header map to the internal invariant: a `Record<string, string>`
* 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<string, string | null | undefined>,
): Record<string, string> {
return Object.fromEntries(
Object.entries(headers).filter(
(entry): entry is [string, string] => entry[1] != null,
),
);
}
export class CopilotKitCore {
private _headers: Record<string, string>;
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<string, string>): 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<string, string | null | undefined>): void {
this._headers = normalizeHeaders(headers);
this.agentRegistry.applyHeadersToAgents(
this.agentRegistry.agents as Record<string, AbstractAgent>,
);
@@ -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
@@ -169,8 +169,8 @@ new CopilotKitCore(config: CopilotKitCoreConfig)
Updates the transport style.
</PropertyReference>
<PropertyReference name="setHeaders(headers: Record<string, string>): void" type="method">
Replaces the request headers and re-applies them to existing agents.
<PropertyReference name="setHeaders(headers: Record<string, string | null | undefined>): void" type="method">
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.
</PropertyReference>
<PropertyReference name="setCredentials(credentials: RequestCredentials | undefined): void" type="method">
+20 -2
View File
@@ -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