Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,8 @@ For now, you will still have the old interfaces available to ease the migration,
### Features

* Update to 0.10.1 of the schema ([#36](https://github.com/agentclientprotocol/typescript-sdk/issues/36)) ([210392b](https://github.com/agentclientprotocol/typescript-sdk/commit/210392bfdcb95d2f515784af914323d2606194f6))
* Unstable: add unstable forkSession support [#37](https://github.com/agentclientprotocol/typescript-sdk/pull/37)
* Unstable: add unstable forkSession support ([#37](https://github.com/agentclientprotocol/typescript-sdk/pull/37)) ([16262ef
](https://github.com/agentclientprotocol/typescript-sdk/commit/16262ef7b52892f935aa7fb39d98657895345ff4))

## [0.8.0](https://github.com/agentclientprotocol/typescript-sdk/compare/v0.7.0...v0.8.0) (2025-12-08)

Expand All @@ -252,7 +253,7 @@ This update provides much improved schema interfaces. The migration should be mi
## 0.5.1 (2025-10-24)

- Add ability for agents and clients to provide information about their implementation
- Fix incorrectly serialized `_meta` field on `SetSessionModeResponse
- Fix incorrectly serialized `_meta` field on `SetSessionModeResponse`

## 0.5.0 (2025-10-24)

Expand Down
6 changes: 3 additions & 3 deletions src/examples/ws-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const authHeaders = {
// uses the platform cookie jar; Node's `ws` uses constructor headers populated
// from this store.
const cookieStore = new MemoryAcpCookieStore();
let savedSessionId: string | undefined;
let reconnectSessionId: string | undefined;

function connect(): {
readonly stream: acp.Stream;
Expand Down Expand Up @@ -77,7 +77,7 @@ try {
cwd: process.cwd(),
mcpServers: [],
});
savedSessionId = session.sessionId;
reconnectSessionId = session.sessionId;

const result = await ctx.request(acp.methods.agent.session.prompt, {
sessionId: session.sessionId,
Expand All @@ -94,7 +94,7 @@ try {

console.log(`\nDone: ${result.stopReason}`);
console.log(
`Saved session ${savedSessionId}; loadSession=${initialized.agentCapabilities?.loadSession === true}`,
`Saved session ${reconnectSessionId}; loadSession=${initialized.agentCapabilities?.loadSession === true}`,
);

// Reconnect flow sketch:
Expand Down
96 changes: 95 additions & 1 deletion src/node-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ describe("createNodeHttpHandler", () => {
},
bodyChunks: [
bodyBytes.slice(0, splitIndex + 1),
"",
bodyBytes.slice(splitIndex + 1),
],
}),
Expand All @@ -245,6 +246,99 @@ describe("createNodeHttpHandler", () => {
expect(seenBodies).toEqual([body]);
});

it("flushes pending UTF-8 bytes before non-empty string chunks", async () => {
const acpServer = new AcpServer({
createAgent: () => createTestAgentApp(),
});
const seenBodies: string[] = [];
const response = new CapturingServerResponse();

acpServer.handleRequest = async (req) => {
seenBodies.push(await req.text());
return new Response("ok");
};

createNodeHttpHandler(acpServer)(
fakeRequest({
method: "POST",
headers: {
"content-type": "text/plain",
},
bodyChunks: [
new Uint8Array([0xf0]),
"x",
new Uint8Array([0x9f, 0x9a, 0x80]),
],
}),
response as unknown as ServerResponse,
);

await response.finished;

expect(response.statusCode).toBe(200);
expect(seenBodies).toEqual(["\uFFFDx\uFFFD\uFFFD\uFFFD"]);
});

it("falls back to localhost for invalid request host headers", async () => {
const acpServer = new AcpServer({
createAgent: () => createTestAgentApp(),
});
const seenUrls: string[] = [];
const response = new CapturingServerResponse();

acpServer.handleRequest = (req) => {
seenUrls.push(req.url);
return Promise.resolve(new Response("ok"));
};

createNodeHttpHandler(acpServer)(
fakeRequest({
headers: {
host: "example.com/@internal",
},
}),
response as unknown as ServerResponse,
);

await response.finished;

expect(response.statusCode).toBe(200);
expect(seenUrls).toEqual(["http://localhost/acp"]);
});

it("preserves valid request host headers that URL parsing canonicalizes", async () => {
const acpServer = new AcpServer({
createAgent: () => createTestAgentApp(),
});
const seenUrls: string[] = [];

acpServer.handleRequest = (req) => {
seenUrls.push(req.url);
return Promise.resolve(new Response("ok"));
};

for (const host of ["EXAMPLE.com", "example.com:80", "[::1]:80"]) {
const response = new CapturingServerResponse();

createNodeHttpHandler(acpServer)(
fakeRequest({
headers: { host },
}),
response as unknown as ServerResponse,
);

await response.finished;

expect(response.statusCode).toBe(200);
}

expect(seenUrls).toEqual([
"http://example.com/acp",
"http://example.com/acp",
"http://[::1]/acp",
]);
});

it("rejects request bodies when Content-Length exceeds the configured limit", async () => {
const acpServer = new AcpServer({
createAgent: () => createTestAgentApp(),
Expand Down Expand Up @@ -491,7 +585,7 @@ function fakeRequest(
options: {
readonly method?: string;
readonly headers?: Record<string, string>;
readonly bodyChunks?: readonly Uint8Array[];
readonly bodyChunks?: readonly (Uint8Array | string)[];
} = {},
): IncomingMessage {
const request = Object.assign(Readable.from(options.bodyChunks ?? []), {
Expand Down
60 changes: 58 additions & 2 deletions src/node-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,10 @@ async function readRequestBody(
}

if (typeof chunk === "string") {
body += decoder.decode();
body +=
chunk.length === 0
? decoder.decode(new Uint8Array(), { stream: true })
: decoder.decode();
body += chunk;
return;
}
Expand Down Expand Up @@ -377,8 +380,61 @@ class RequestBodyTooLargeError extends Error {
}
}

function sanitizedRequestHost(req: IncomingMessage): string {
const hostHeader = req.headers.host;
const host = Array.isArray(hostHeader) ? hostHeader[0] : hostHeader;

if (host === undefined) {
return "localhost";
}

const normalized = host.trim();

if (normalized.length === 0 || hasInvalidRequestHostCharacter(normalized)) {
return "localhost";
}

try {
const parsed = new URL(`http://${normalized}`);
if (
parsed.username !== "" ||
parsed.password !== "" ||
parsed.pathname !== "/" ||
parsed.search !== "" ||
parsed.hash !== ""
) {
return "localhost";
}
} catch {
return "localhost";
}

return normalized;
}

function hasInvalidRequestHostCharacter(host: string): boolean {
for (const char of host) {
const codePoint = char.codePointAt(0);
if (
codePoint === undefined ||
codePoint <= 0x1f ||
codePoint === 0x7f ||
char.trim() === "" ||
char === "/" ||
char === "?" ||
char === "#" ||
char === "@" ||
char === "\\"
) {
return true;
}
}

return false;
}

function nodeRequestUrl(req: IncomingMessage): string {
const host = req.headers.host ?? "localhost";
const host = sanitizedRequestHost(req);
return `http://${host}${req.url ?? "/"}`;
}

Expand Down