Skip to content

Commit c8a84c5

Browse files
committed
Add session affinity using cookie store
1 parent 19bc47a commit c8a84c5

18 files changed

Lines changed: 5194 additions & 202 deletions

acp-web-transport/design.md

Lines changed: 986 additions & 0 deletions
Large diffs are not rendered by default.

acp-web-transport/rfd.md

Lines changed: 402 additions & 0 deletions
Large diffs are not rendered by default.

acp-web-transport/tasks.md

Lines changed: 1701 additions & 0 deletions
Large diffs are not rendered by default.

acp-web-transport/testing.md

Lines changed: 833 additions & 0 deletions
Large diffs are not rendered by default.

src/cookie-store.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { MemoryAcpCookieStore } from "./cookie-store.js";
4+
5+
describe("MemoryAcpCookieStore", () => {
6+
it("stores single and multiple Set-Cookie values", () => {
7+
const store = new MemoryAcpCookieStore();
8+
store.store(headersWithSetCookie(["transport=alpha; Path=/"]));
9+
store.store(
10+
headersWithSetCookie(["route=bravo; Path=/", "affinity=charlie"]),
11+
);
12+
13+
const headers = new Headers();
14+
store.apply(headers);
15+
16+
expect(headers.get("Cookie")).toBe(
17+
"transport=alpha; route=bravo; affinity=charlie",
18+
);
19+
});
20+
21+
it("splits combined Set-Cookie headers with Expires commas", () => {
22+
const store = new MemoryAcpCookieStore();
23+
store.store(
24+
new Headers({
25+
"Set-Cookie":
26+
"transport=alpha; Expires=Wed, 21 Oct 2030 07:28:00 GMT, route=bravo; Path=/",
27+
}),
28+
);
29+
30+
const headers = new Headers();
31+
store.apply(headers);
32+
33+
expect(headers.get("Cookie")).toBe("transport=alpha; route=bravo");
34+
});
35+
36+
it("ignores malformed cookie headers", () => {
37+
const store = new MemoryAcpCookieStore();
38+
store.store(
39+
headersWithSetCookie([
40+
"missing-separator",
41+
"=empty-name",
42+
" =blank",
43+
"ok=value",
44+
]),
45+
);
46+
47+
const headers = new Headers();
48+
store.apply(headers);
49+
50+
expect(headers.get("Cookie")).toBe("ok=value");
51+
});
52+
53+
it("lets later cookies overwrite earlier cookies with the same name", () => {
54+
const store = new MemoryAcpCookieStore();
55+
store.store(headersWithSetCookie(["route=alpha", "route=bravo"]));
56+
57+
const headers = new Headers();
58+
store.apply(headers);
59+
60+
expect(headers.get("Cookie")).toBe("route=bravo");
61+
});
62+
63+
it("writes a Cookie header when managed cookies exist", () => {
64+
const store = new MemoryAcpCookieStore();
65+
store.store(headersWithSetCookie(["transport=alpha"]));
66+
67+
const headers = new Headers();
68+
store.apply(headers);
69+
70+
expect(headers.get("Cookie")).toBe("transport=alpha");
71+
});
72+
73+
it("merges managed cookies with caller-provided Cookie headers", () => {
74+
const store = new MemoryAcpCookieStore();
75+
store.store(headersWithSetCookie(["transport=alpha", "route=bravo"]));
76+
77+
const headers = new Headers({ Cookie: "caller=custom" });
78+
store.apply(headers);
79+
80+
expect(headers.get("Cookie")).toBe(
81+
"transport=alpha; route=bravo; caller=custom",
82+
);
83+
});
84+
85+
it("lets caller-provided cookie values override managed duplicate names", () => {
86+
const store = new MemoryAcpCookieStore();
87+
store.store(headersWithSetCookie(["transport=alpha", "route=bravo"]));
88+
89+
const headers = new Headers({ Cookie: "route=caller; caller=custom" });
90+
store.apply(headers);
91+
92+
expect(headers.get("Cookie")).toBe(
93+
"transport=alpha; route=caller; caller=custom",
94+
);
95+
});
96+
97+
it("clears managed cookies", () => {
98+
const store = new MemoryAcpCookieStore();
99+
store.store(headersWithSetCookie(["transport=alpha"]));
100+
store.clear();
101+
102+
const headers = new Headers();
103+
store.apply(headers);
104+
105+
expect(headers.get("Cookie")).toBeNull();
106+
});
107+
});
108+
109+
function headersWithSetCookie(values: readonly string[]): Headers {
110+
const headers = new Headers();
111+
112+
Object.defineProperty(headers, "getSetCookie", {
113+
value: () => values,
114+
});
115+
116+
return headers;
117+
}

src/cookie-store.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* Minimal ACP affinity cookie store.
3+
*
4+
* This helper stores cookie name/value pairs from `Set-Cookie` response
5+
* headers and applies them to outgoing `Cookie` request headers. It is meant
6+
* for ACP routing affinity across reconnects, not authentication or
7+
* authorization, and not as a general-purpose browser cookie jar: it
8+
* intentionally does not implement domain/path matching,
9+
* expiry, `Secure`, `HttpOnly`, or `SameSite` handling.
10+
*/
11+
export interface AcpCookieStore {
12+
/** Stores cookies from response headers. */
13+
store(headers: Headers): void;
14+
/** Applies stored cookies to outgoing request headers. */
15+
apply(headers: Headers): void;
16+
/** Clears all stored cookies. */
17+
clear(): void;
18+
}
19+
20+
/** In-memory implementation of {@link AcpCookieStore}. */
21+
export class MemoryAcpCookieStore implements AcpCookieStore {
22+
private readonly cookies = new Map<string, string>();
23+
24+
store(headers: Headers): void {
25+
for (const value of setCookieHeaders(headers)) {
26+
const cookie = parseSetCookie(value);
27+
if (!cookie) {
28+
continue;
29+
}
30+
31+
this.cookies.set(cookie.name, cookie.value);
32+
}
33+
}
34+
35+
apply(headers: Headers): void {
36+
const merged = mergeCookieHeaders(
37+
this.cookieHeader(),
38+
headers.get("Cookie"),
39+
);
40+
if (merged) {
41+
headers.set("Cookie", merged);
42+
}
43+
}
44+
45+
clear(): void {
46+
this.cookies.clear();
47+
}
48+
49+
private cookieHeader(): string | undefined {
50+
return this.cookies.size === 0
51+
? undefined
52+
: Array.from(this.cookies)
53+
.map(([name, value]) => `${name}=${value}`)
54+
.join("; ");
55+
}
56+
}
57+
58+
interface CookiePair {
59+
readonly name: string;
60+
readonly value: string;
61+
}
62+
63+
function setCookieHeaders(headers: Headers): string[] {
64+
const getSetCookie = headers.getSetCookie;
65+
if (typeof getSetCookie === "function") {
66+
return getSetCookie.call(headers).flatMap(splitSetCookieHeader);
67+
}
68+
69+
const setCookie = headers.get("Set-Cookie");
70+
return setCookie ? splitSetCookieHeader(setCookie) : [];
71+
}
72+
73+
function splitSetCookieHeader(header: string): string[] {
74+
return header
75+
.split(/,(?=\s*[^;,\s]+=)/)
76+
.map((value) => value.trim())
77+
.filter((value) => value.length > 0);
78+
}
79+
80+
function parseSetCookie(header: string): CookiePair | undefined {
81+
const pair = header.split(";", 1)[0];
82+
const separator = pair.indexOf("=");
83+
84+
if (separator <= 0) {
85+
return undefined;
86+
}
87+
88+
const name = pair.slice(0, separator).trim();
89+
if (!name) {
90+
return undefined;
91+
}
92+
93+
return {
94+
name,
95+
value: pair.slice(separator + 1).trim(),
96+
};
97+
}
98+
99+
function mergeCookieHeaders(
100+
managedCookieHeader: string | undefined,
101+
callerCookieHeader: string | null,
102+
): string | undefined {
103+
const cookies = new Map<string, string>();
104+
105+
for (const cookie of parseCookieHeader(managedCookieHeader)) {
106+
cookies.set(cookie.name, cookie.value);
107+
}
108+
109+
for (const cookie of parseCookieHeader(callerCookieHeader ?? undefined)) {
110+
cookies.set(cookie.name, cookie.value);
111+
}
112+
113+
return cookies.size === 0
114+
? undefined
115+
: Array.from(cookies)
116+
.map(([name, value]) => `${name}=${value}`)
117+
.join("; ");
118+
}
119+
120+
function parseCookieHeader(header: string | undefined): CookiePair[] {
121+
if (!header) {
122+
return [];
123+
}
124+
125+
return header
126+
.split(";")
127+
.map(parseCookiePair)
128+
.filter((cookie): cookie is CookiePair => cookie !== undefined);
129+
}
130+
131+
function parseCookiePair(value: string): CookiePair | undefined {
132+
const separator = value.indexOf("=");
133+
134+
if (separator <= 0) {
135+
return undefined;
136+
}
137+
138+
const name = value.slice(0, separator).trim();
139+
if (!name) {
140+
return undefined;
141+
}
142+
143+
return {
144+
name,
145+
value: value.slice(separator + 1).trim(),
146+
};
147+
}

src/examples/http-client.ts

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
#!/usr/bin/env node
22

33
import * as acp from "@agentclientprotocol/sdk";
4-
import { createHttpStream } from "@agentclientprotocol/sdk/http-client";
4+
import {
5+
MemoryAcpCookieStore,
6+
createHttpStream,
7+
} from "@agentclientprotocol/sdk/http-client";
58

69
class HttpExampleClient implements acp.Client {
710
async requestPermission(
@@ -30,19 +33,36 @@ class HttpExampleClient implements acp.Client {
3033
}
3134

3235
const serverUrl = process.env.ACP_HTTP_URL ?? "http://127.0.0.1:7331/acp";
33-
const stream = createHttpStream(serverUrl, {
34-
headers: {
35-
Authorization: "Bearer example-token",
36-
},
37-
// Cookies are included by default and scoped to this stream. Use `cookies: "omit"` for stateless requests.
38-
});
39-
const connection = new acp.ClientSideConnection(
40-
(_agent) => new HttpExampleClient(),
41-
stream,
42-
);
36+
const authHeaders = {
37+
Authorization: "Bearer example-token",
38+
};
39+
40+
// Keep reconnect state outside individual stream instances. Reuse this store
41+
// across fresh streams so an external affinity layer can route reconnects.
42+
const cookieStore = new MemoryAcpCookieStore();
43+
let savedSessionId: string | undefined;
44+
45+
function connect(): {
46+
readonly stream: acp.Stream;
47+
readonly connection: acp.ClientSideConnection;
48+
} {
49+
const stream = createHttpStream(serverUrl, {
50+
headers: authHeaders,
51+
cookieStore,
52+
// Cookies are included by default. Use `cookies: "omit"` for stateless requests.
53+
});
54+
const connection = new acp.ClientSideConnection(
55+
(_agent) => new HttpExampleClient(),
56+
stream,
57+
);
58+
59+
return { stream, connection };
60+
}
61+
62+
const { stream, connection } = connect();
4363

4464
try {
45-
await connection.initialize({
65+
const initialized = await connection.initialize({
4666
protocolVersion: acp.PROTOCOL_VERSION,
4767
clientCapabilities: {},
4868
});
@@ -51,6 +71,7 @@ try {
5171
cwd: process.cwd(),
5272
mcpServers: [],
5373
});
74+
savedSessionId = session.sessionId;
5475

5576
const result = await connection.prompt({
5677
sessionId: session.sessionId,
@@ -63,6 +84,22 @@ try {
6384
});
6485

6586
console.log(`\nDone: ${result.stopReason}`);
87+
88+
console.log(
89+
`Saved session ${savedSessionId}; loadSession=${initialized.agentCapabilities?.loadSession === true}`,
90+
);
91+
92+
// Reconnect flow sketch:
93+
// 1. Save `sessionId`, auth headers, cwd, MCP servers, and `cookieStore`.
94+
// 2. Create a fresh stream with the same auth headers and cookie store.
95+
// 3. Call initialize and require `agentCapabilities.loadSession`.
96+
// 4. Call session/load for the saved session ID.
97+
// Production agents must authorize session/load for the authenticated user.
98+
// ACP v1 does not replay in-flight transport messages emitted while disconnected.
99+
// Example:
100+
// const next = connect();
101+
// await next.connection.initialize({ protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: {} });
102+
// await next.connection.loadSession({ sessionId: savedSessionId, cwd: process.cwd(), mcpServers: [] });
66103
} finally {
67104
await stream.writable.close();
68105
}

0 commit comments

Comments
 (0)