mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user